diff --git a/.gitignore b/.gitignore index 8865e97b..f9d798c2 100644 --- a/.gitignore +++ b/.gitignore @@ -222,11 +222,7 @@ __marimo__/ .ralphex/ docs/plans/ docs/design/ -docs/arch/ -docs/tasks/ -docs/defines/ -docs/proposals/ docs/superpowers/ -# Orchestration run artifacts -/plan.md +# Goga +.goga/history \ No newline at end of file diff --git a/.goga/config.yml b/.goga/config.yml index f4c2f8b0..79e97b85 100644 --- a/.goga/config.yml +++ b/.goga/config.yml @@ -13,6 +13,12 @@ build: env: <<: *claude-env ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" + review_executor: + agent: claude + base_ref: release/1.3.0 + env: + <<: *claude-env + ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3[1m]" pipeline: agent: claude @@ -20,6 +26,11 @@ pipeline: <<: *claude-env ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3[1m]" +lint: + ignore: + - build/ + - .venv/ + codemanifest: usages: convention: .goga/usages/conventions.md @@ -30,7 +41,3 @@ codemanifest: - Debugging and testing - Organizing the test infrastructure - Understanding the general principles and rules of development and testing in the project - -lint: - ignore: - - build/ diff --git a/.goga/memory/architecture.md b/.goga/memory/architecture.md new file mode 100644 index 00000000..2b97c85b --- /dev/null +++ b/.goga/memory/architecture.md @@ -0,0 +1,123 @@ +# Project rules — architecture + +## Dependency edges target the owner's facade and respect the fixed direction + +All interaction with a subsystem's capabilities — code dependencies and documentation alike — targets the owning unit's +public surface. Internal sub-units are never direct dependency targets; nested capabilities publish their contracts at +the owner's level, and reuse happens through the owner's re-export, never by linking into the depths. + +When a unit accumulates several functional zones (data, registry, dispatch, access to an external system), it is split +into leaf sub-units by zone, with the main API re-exported on the parent facade; consumers import only the facade. + +Direction is part of the same law: dependency direction between domains is fixed and one-way, and a reverse edge is +never introduced, whatever reuse it would buy — it creates a cycle that surfaces too late. When the fixed direction +puts a capability out of reach, the fallback is a consumer-side variant, never an edge shortcut. + +## Single access zone per external system + +All operations that reach one external system inside a domain belong to exactly one dedicated leaf unit that owns the +access, mirrors the structure of the existing access leaves, exposes a minimal public surface, and is consumed only +through the domain facade. When the access happens and with what content remains the responsibility of consumer +orchestrations. New capabilities extend that unit's zone instead of spawning a parallel sibling — even when the +extension forces an exception to the zone's established invariants. Extending a zone never rewrites already published +contract fragments: their invariants stay verbatim, and every new allowance is recorded only in the fragments of the +new elements. + +## Core-anchored invariants and shared parameters + +Guarantees that must hold for every caller are specified and enforced in the core domain contracts, never at a single +entry point; a rule guarded inside one command counts as unenforced, because every other caller could bypass it. The +same law governs the command surface: a parameter shared by every subcommand of a command group is declared once on +the group itself and applied implicitly by all subcommands — subcommand surfaces and declared signatures carry no copy +of it. The mechanism that transports the value to the subcommands is an implementation detail kept out of the +contract. + +## Layered responsibility for external inputs + +Environment coupling lives at the boundary layer, never in the domain core. The boundary layer resolves external +inputs — source precedence of explicit argument over configuration over built-in default — and passes primitive values +inward; interactive prompting that resolves a missing input belongs to the outer command layer, and a domain routine +that must interact detects the non-interactive terminal and fails with a clean error — keeping the domain core usable +from non-interactive callers and inner layers independently testable. Command callbacks stay thin in the +same spirit: they only resolve inputs, delegate to domain routines, and render results, passing values through as +opaque data without validating or re-interpreting them — grammar and normalization rules for a value +belong exclusively to the domain module. The domain core exposes all-or-nothing read-only resolution with clean errors +and mutation routines that run unconditionally once the caller has confirmed. The value provider performs structural +validation only (type and shape), stores values verbatim, embeds no defaults, and checks no semantics — semantic +interpretation and defaulting belong to the consumer. + +## Graded outcome-to-exit mapping + +Absence of data or an empty result is a successful run with empty output, never a failure. Usage mistakes and domain +failures are kept distinct and map to separate standardized non-zero exit codes, each reported to the user as one +clean message — internal tracebacks never reach the output. + +## Additive regression-free extension + +New functionality enters as a new unit beside the existing ones, never as a mode inside an existing unit. When +behavior is added to an existing routine instead, it arrives as an optional parameter so every current caller stays +valid and unchanged, and invocation forms that remain supported stay observationally identical in output shape and +exit behavior; no parallel routines duplicating existing logic are ever introduced. Existing observable behavior, its +contracts, and its tests are not edited and do not acquire new dependencies — including reads of new data sources. +Data-model extensions arrive as optional fields with a safe default so every existing construction site stays valid +without edits. Migrating existing functionality onto a new platform follows the same spirit as a near-rename: domain +objects move unchanged, and only the source of registrations changes (the cell emits the platform's action instead of +running its own enumeration mechanism). + +## Decisions before mutations, with compensating rollback + +Orchestrating algorithms order every read-only check and validation before the first state change. Before any +irreversible step of a multi-step mutation, the state needed to undo it is captured; when a later step fails, prior +effects are restored by composing existing primitives, exactly one clean error with the root cause is reported, and a +repeated invocation stays safe. The rollback is scoped to the failed sequence — work completed outside it deliberately +remains. Rollback mechanisms belong to the access layer; the decision to roll back belongs to the caller. + +## Mechanism-agnostic contracts + +Contracts express only the abstract order of actions through references to practices and types. Concrete mechanisms, +tool choices, and lifecycle detail are fixed in separate project-level practice documents with executable guidance, +never inside contract annotations. + +## Closed binding of names in a contract + +Every name declared as an imported dependency must be referenced within the contract's own text, and every mention must +resolve within that same contract: either through a declared dependency or through a locally declared practice (when a +direct dependency is impossible — cycles, unreachability). No dangling declarations, no free-floating mentions — +otherwise the contract cannot stand alone and the implementation cannot be rebuilt from it. References use the +contract's own notation, without procedural phrases about where names come from. + +## Names state their scope + +An operation's name states its exact coverage — never broader than what it does (no implying remote-side effects of a +local-only operation), never narrower. Scope inaccuracy in a name is a contract defect; a rename is applied across all +already produced artifacts so that stages never disagree on names. + +## Specialization lives with the consumer + +When a domain needs its own variant of a shared capability, the variant is created inside the consumer's zone. A +provider's internal units are never extended to serve one specific consumer — misplacement distorts the ownership map, +and moving code after materialization is a full migration. + +## Mode-based safety of destructive operations + +Protection in destructive operations comes from explicit prior modes, not from value-based exemptions. A no-execution +report mode previews the full effect before anything is removed; execution itself is unconditional — record attributes +never protect a record from removal. An operation is either unconditional or explicitly scoped by the caller; sparing +modes keyed to the data being destroyed are not invented. + +## Stage artifact purity + +A process stage produces only its designated artifact type; transformations belonging to later stages never start early. +A planning stage does not modify implementation artifacts — materialization belongs to the next stage. Mixing planning +with materialization destroys the workflow's guarantees: unreviewed code changes without an approved plan. + +## One document — one behavior domain + +Consumer documentation is structured by behavior domain: a new domain gets its own self-contained document, documents of +unchanged behavior are not edited, and cross-references between sibling documents are not introduced. The set of +documents to touch is decided by this rule, not by the task's original list. + +## Fix-in-place verification gates + +Defects surfaced by verification are repaired in the artifact itself, and the complete check suite is re-run to green +before approval. Approving with known breakage and deferring the repair to a later stage is rejected. diff --git a/.goga/memory/code-design.md b/.goga/memory/code-design.md new file mode 100644 index 00000000..53e0bd8c --- /dev/null +++ b/.goga/memory/code-design.md @@ -0,0 +1,24 @@ +# Project rules — code design + +## Independent root-cause isolation + +When multiple failure classes arrive together, reproduce and prove each cause separately against authoritative material +— official documentation and the actual runtime sources — before fixing anything; never assume a single shared cause or +diagnose solely from an aggregated CI report. + +## Specification-implementation co-evolution + +When a defect surfaces at a contract boundary, fix the implementation to satisfy the existing specification and amend +the specification only to state the boundary explicitly, then confirm consistency through the project's specification +validation gates; never rewrite the specification to legitimize buggy behavior. + +## Explicit version-stable validation + +Encode edge-case semantics explicitly in production validation logic so behavior is identical on every supported runtime +version, rather than relying on standard-library behavior that silently changes between versions. + +## Honest verification scope + +Validate on the environments where the defects actually reproduce, rely on existing parameterized coverage and the CI +matrix for environments unavailable locally, disclose local-coverage gaps explicitly in every report, and never +fabricate coverage by mocking internal components to force unreachable branches. diff --git a/.goga/tools/mkdocs/traceability.yml b/.goga/tools/mkdocs/traceability.yml index ac475fcf..1a1e1760 100644 --- a/.goga/tools/mkdocs/traceability.yml +++ b/.goga/tools/mkdocs/traceability.yml @@ -1,6 +1,7 @@ # Traceability: documentation page → cell paths # Each entry maps a doc page to the cells whose CODEMANIFEST/.usages it draws from. + README.md: - goga - goga/commands/install @@ -10,6 +11,12 @@ README.md: - goga/build - goga/commands/build - goga/ralphex + - goga/topics + - goga/commands/topics + - goga/history + - goga/commands/history + - goga/history/statuses + - goga/hooks docs/index.md: - goga @@ -20,7 +27,6 @@ docs/index.md: - goga/commands/install - goga/connect - goga/docker - docs/getting-started.md: - goga/onboarding - goga/config @@ -29,171 +35,338 @@ docs/getting-started.md: - goga/pipeline - goga/commands/pipeline -docs/cell/index.md: - - goga +docs/cli/index.md: + - goga/commands -docs/cell/codemanifest.md: +docs/features/index.md: - goga -docs/cell/usages.md: - - goga +docs/features/topics/index.md: + - goga/topics + - goga/commands/topics + - goga/history +docs/features/topics/cli.md: + - goga/commands/topics + - goga/topics + - goga/topics/git + - goga/topics/editor + - goga/history + - goga/history/statuses + - goga/config +docs/features/topics/configuration.md: + - goga/config + - goga/config/project + - goga/commands/topics +docs/features/topics/hooks.md: + - goga/history/statuses + - goga/hooks +docs/features/topics/api.md: + - goga/topics + - goga/topics/git + - goga/topics/editor + +docs/features/history/index.md: + - goga/history + - goga/commands/history +docs/features/history/cli.md: + - goga/commands/history + - goga/history + - goga/history/statuses +docs/features/history/configuration.md: + - goga/history +docs/features/history/hooks.md: + - goga/history + - goga/history/statuses + - goga/hooks +docs/features/history/api.md: + - goga/history + - goga/history/git + - goga/history/statuses + +docs/features/pipelines/index.md: + - goga/pipeline + - goga/pipeline/compiler + - goga/pipeline/workflow + - goga/commands/pipeline + - goga/connect +docs/features/pipelines/cli.md: + - goga/commands/pipeline + - goga/pipeline + - goga/docker + - goga/topics +docs/features/pipelines/configuration.md: + - goga/config + - goga/config/project + - goga/commands/pipeline +docs/features/pipelines/hooks.md: + - goga/pipeline + - goga/hooks +docs/features/pipelines/api.md: + - goga/pipeline + - goga/pipeline/workflow + - goga/pipeline/compiler +docs/features/pipelines/pipeline-file.md: + - goga/pipeline + - goga/pipeline/compiler +docs/features/pipelines/workflows.md: + - goga/pipeline/workflow + - goga/pipeline/compiler + - goga/commands/pipeline +docs/features/pipelines/shipped.md: + - goga/pipeline + - goga/connect + # YAML assets directory, not a CODEMANIFEST cell — rule exception. + - goga/assets/pipelines -docs/configuration/index.md: +docs/features/build/index.md: + - goga/build + - goga/commands/build + - goga/ralphex +docs/features/build/cli.md: + - goga/commands/build + - goga/build + - goga/ralphex +docs/features/build/configuration.md: + - goga/config + - goga/config/project + - goga/build +docs/features/build/hooks.md: + - goga/build + - goga/hooks +docs/features/build/api.md: + - goga/build + +docs/features/tools/index.md: + - goga/connect + - goga/commands/tool + - goga/commands/install + - goga/pipeline + - goga/hooks +docs/features/tools/cli.md: + - goga/commands/tool +docs/features/tools/configuration.md: + - goga/commands/tool + - goga/commands/install +docs/features/tools/hooks.md: + - goga/hooks + - goga/commands/install +docs/features/tools/api.md: + - goga/commands/tool + +docs/features/connect/index.md: + - goga/connect + - goga/commands/connect +docs/features/connect/cli.md: + - goga/commands/connect + - goga/connect +docs/features/connect/configuration.md: + - goga/connect + - goga/config/home +docs/features/connect/hooks.md: + - goga/connect + - goga/hooks +docs/features/connect/api.md: + - goga/connect + +docs/features/upgrade/index.md: + - goga/commands/upgrade +docs/features/upgrade/cli.md: + - goga/commands/upgrade +docs/features/upgrade/configuration.md: + - goga/commands/upgrade + - goga/config/home +docs/features/upgrade/hooks.md: + - goga/commands/upgrade +docs/features/upgrade/api.md: + - goga/commands/upgrade + +docs/features/install/index.md: + - goga/commands/install + - goga/commands/connect +docs/features/install/cli.md: + - goga/commands/install +docs/features/install/uninstall.md: + - goga/commands/install + - goga/connect +docs/features/install/configuration.md: + - goga/config + - goga/config/project + - goga/commands/install +docs/features/install/hooks.md: + - goga/commands/install +docs/features/install/api.md: + - goga/commands/install + +docs/features/init/index.md: + - goga/commands/init + - goga/onboarding + - goga/scaffold +docs/features/init/cli.md: + - goga/commands/init + - goga/onboarding + - goga/scaffold + - goga/config/git +docs/features/init/configuration.md: + - goga/onboarding + - goga/config +docs/features/init/hooks.md: + - goga/onboarding +docs/features/init/api.md: + - goga/onboarding + +docs/features/usages/index.md: + - goga/usages + - goga/commands/usages +docs/features/usages/cli.md: + - goga/commands/usages + - goga/usages +docs/features/usages/configuration.md: + - goga/config + - goga/config/project + - goga/usages +docs/features/usages/hooks.md: + - goga/usages +docs/features/usages/api.md: + - goga/usages + - goga/usages/sync + - goga/usages/status + +docs/features/schema/index.md: + - goga/schema + - goga/commands/schema +docs/features/schema/cli.md: + - goga/commands/schema + - goga/schema +docs/features/schema/configuration.md: + - goga/schema +docs/features/schema/hooks.md: + - goga/schema +docs/features/schema/api.md: + - goga/schema + +docs/features/contract/index.md: + - goga/contract + - goga/commands/contract +docs/features/contract/cli.md: + - goga/commands/contract + - goga/contract +docs/features/contract/configuration.md: + - goga/contract + - goga/config +docs/features/contract/hooks.md: + - goga/contract +docs/features/contract/api.md: + - goga/contract + - goga/contract/data + +docs/features/hooks/index.md: + - goga/hooks + - goga/commands/hooks +docs/features/hooks/cli.md: + - goga/commands/hooks + - goga/hooks +docs/features/hooks/configuration.md: + - goga/hooks +docs/features/hooks/hooks.md: + - goga/hooks + - goga/hooks/catalog + - goga/hooks/dispatch + - goga/hooks/registry + - goga/hooks/tools +docs/features/hooks/api.md: + - goga/hooks + - goga/hooks/catalog + - goga/hooks/dispatch + - goga/hooks/registry + - goga/hooks/tools + +docs/features/lint/index.md: + - goga/commands/lint + - goga/ast +docs/features/lint/cli.md: + - goga/commands/lint + - goga/ast +docs/features/lint/configuration.md: - goga/config + - goga/config/project + - goga/commands/lint +docs/features/lint/hooks.md: + - goga/ast +docs/features/lint/api.md: + - goga/ast +docs/features/lint/errors.md: + - goga/ast/rules + - goga/ast/rules/base + - goga/ast/rules/document/imports + - goga/ast/rules/document/usages + - goga/ast/rules/document/structures + - goga/ast/rules/document/mutation + - goga/ast/rules/document/annotations + - goga/ast/rules/ast +docs/configuration/index.md: + - goga/config docs/configuration/project.md: - goga/config + - goga/config/project - goga/docker - docs/configuration/home.md: - goga/config/home - goga/docker - docs/configuration/agents.md: - goga/config - goga/agents/wrapper - goga/docker +docs/configuration/cli.md: + - goga/commands/config + - goga/config -docs/tools.md: - - goga/connect - - goga/commands/tool - - goga/commands/install - - goga/pipeline +docs/cell/index.md: + - goga +docs/cell/codemanifest.md: + - goga +docs/cell/usages.md: + - goga docs/workflow/index.md: - goga/connect - docs/workflow/define.md: - goga/connect - docs/workflow/discover.md: - goga/connect - docs/workflow/propose.md: - goga/connect - docs/workflow/review.md: - goga/connect - docs/workflow/brainstorm.md: - goga/connect - docs/workflow/apply.md: - goga/connect - docs/workflow/design.md: - goga/connect - docs/workflow/plan.md: - goga/connect - docs/workflow/build.md: - goga/build - goga/commands/build - goga/ralphex - docs/workflow/change.md: - goga/connect - docs/workflow/accept.md: - goga/connect -docs/cli/index.md: - - goga/commands - -docs/cli/init.md: - - goga/commands/init - - goga/onboarding - - goga/scaffold - - goga/config/git - -docs/cli/lint.md: - - goga/commands/lint - - goga/ast - -docs/cli/build.md: - - goga/commands/build - - goga/build - - goga/ralphex - -docs/cli/contract.md: - - goga/commands/contract - - goga/contract - -docs/cli/config.md: - - goga/commands/config - - goga/config - -docs/cli/schema.md: - - goga/commands/schema - - goga/schema - -docs/cli/connect.md: - - goga/commands/connect - - goga/connect - -docs/cli/tool.md: - - goga/commands/tool - -docs/cli/install.md: - - goga/commands/install - -docs/cli/uninstall.md: - - goga/commands/install - - goga/connect - -docs/cli/upgrade.md: - - goga/commands/upgrade - -docs/cli/usages.md: - - goga/commands/usages - - goga/usages - -docs/cli/pipeline.md: - - goga/commands/pipeline - - goga/pipeline - - goga/docker - -docs/pipelines/index.md: - - goga/pipeline - - goga/pipeline/compiler - - goga/pipeline/workflow - - goga/commands/pipeline - - goga/connect - -docs/pipelines/pipeline-file.md: - - goga/pipeline - - goga/pipeline/compiler - -docs/pipelines/workflows.md: - - goga/pipeline/workflow - - goga/pipeline/compiler - - goga/commands/pipeline - -docs/pipelines/shipped.md: - - goga/pipeline - - goga/connect - # YAML assets directory, not a CODEMANIFEST cell — see rule exception below. - - goga/assets/pipelines - docs/architecture/index.md: - goga/ast - docs/architecture/ast-nodes.md: - goga/ast/nodes - docs/architecture/ast-factory.md: - goga/ast/factory - docs/architecture/ast-visitor.md: - goga/ast/visitor - docs/architecture/ast-analyzer.md: - goga/ast/analyzer - docs/architecture/ast-errors.md: - goga/ast/errors - docs/architecture/validation-rules.md: - goga/ast/rules - goga/ast/rules/base @@ -203,25 +376,19 @@ docs/architecture/validation-rules.md: - goga/ast/rules/document/mutation - goga/ast/rules/document/annotations - goga/ast/rules/ast - docs/architecture/contract-extraction.md: - goga/contract - goga/contract/data docs/languages/index.md: - goga/contract - docs/languages/python.md: - goga/contract/python - docs/languages/golang.md: - goga/contract/golang - docs/languages/kotlin.md: - goga/contract/kotlin - docs/languages/swift.md: - goga/contract/swift - docs/languages/javascript.md: - goga/contract/javascript diff --git a/.goga/usages/cooks/afm.md b/.goga/usages/cooks/afm.md index b020bded..10a61958 100644 --- a/.goga/usages/cooks/afm.md +++ b/.goga/usages/cooks/afm.md @@ -113,8 +113,52 @@ afm honors the following additional per-stage keys inside each stage of a flow-f | `script` | str | Shell script run as the stage's action (mutually exclusive with the stage's `prompt`/`skills`). | | `script_after` | str | Shell script run after the stage's agent invocation. | | `script_timeout` | str | Timeout for the stage's script action (Go duration), applied via afm's script-timeout defaults. Authored by the goga pipeline compiler from a `timeout` stage directive; the value passes verbatim — a malformed duration surfaces at runtime. | +| `buttons` | map[str]str | Per-stage note buttons — a map of "button name → prompt text". Accepted by `afm validate` (single- and multi-line values); in the current binary the key is not yet interpreted (forward-compat) — it is neither rejected nor processed. Compiled by the goga workflow layer from a `notes` instruction (`workflow.stages..notes`). | +| `reflect` | map | Per-stage memory reflection — a map with a required `file` key (str) and an optional `mode` key (`r`/`w`/`rw`). afm treats `file` as a path INSIDE the flow's `memory.path`. Compiled by the goga workflow layer from a `reflect` instruction (`workflow.stages..reflect`, reflect method); goga materializes `mode: rw` when the authoring entry omits it. | +| `memory_use` | *bool | Per-stage participation in the flow's memory. An unset key INHERITS the global `memory.memory_use` (afm computes `UseFor(stage) = stage.memory_use ?? memory.memory_use`), so the goga pipeline compiler emits an explicit `memory_use: false` on every unmarked stage whenever the global `memory` block is emitted. Compiled from a workflow `memory: true` instruction (alignment method). | -These keys are optional per stage; stages that do not carry them behave as before (backward compatible). goga authors `auto_approve` from its `approve: auto` workflow directive, translates its authoring `before_script`/`script`/`after_script` stage-body keys into `script_before`/`script`/`script_after`, compiles its authoring `timeout` stage directive into `script_timeout` (verbatim), and authors `auto_run: false` from a `trigger: manual` stage directive or a workflow `manual: true` instruction (never `true`; the key's absence is the norm). +These keys are optional per stage; stages that do not carry them behave as before (backward compatible). goga authors `auto_approve` from its `approve: auto` workflow directive, translates its authoring `before_script`/`script`/`after_script` stage-body keys into `script_before`/`script`/`script_after`, compiles its authoring `timeout` stage directive into `script_timeout` (verbatim), and authors `auto_run: false` from a `trigger: manual` stage directive or a workflow `manual: true` instruction (never `true`; the key's absence is the norm). goga compiles its workflow `notes` instruction (map str→str) into the per-stage `buttons` field; the interpretation of the buttons belongs to afm (a separate repository) — goga only serializes the field. + +## Memory mechanism (flow-file, afm v0.5.60+) + +afm supports a persistent-memory mechanism authored through the flow-file: a global +`memory` block plus two per-stage keys (`reflect`, `memory_use`). + +### Global `memory` block + +A top-level flow-file key placed after `description` and before `stages`, with the key +order `path`, `mode`, `memory_use`, `max_rules`, `commit`: + +| Key | Type | Purpose | +|-----|------|---------| +| `path` | str | Memory directory (e.g. `.goga/memory`); a stage's `reflect.file` resolves inside it | +| `mode` | str (`r`/`w`/`rw`) | Project-memory access mode | +| `memory_use` | bool | Global participation default inherited by stages that carry no `memory_use` of their own | +| `max_rules` | int (>= 1) | Maximum number of memory rules | +| `commit` | bool | Whether memory changes are committed | + +### Semantics (recovered from the afm binary) + +- `UseFor(stage) = stage.memory_use ?? memory.memory_use` — a stage key that is not set + inherits the global value. +- `CanReadProject` / `CanWriteProject` check EXACT equality of `mode` against + `r`/`w`/`rw`: an empty `mode` means neither read nor write (NOT `rw`). +- `reflect.file` is treated as a path INSIDE `memory.path`. +- The afm-side default of `max_rules` is not locatable in the binary. +- afm does NOT reject unknown keys — a typo in a key name passes silently, which is why + goga performs the full authoring validation on its own side. + +### goga authoring stance + +goga authors memory in the workflow-file (the `memory:` block plus the `reflect`/`memory` +stage instructions) and compiles it into the flow-file; the runtime interpretation belongs +to afm. The global block is emitted if and only if at least one stage participates in +memory; defaults are materialized (`max_rules: 25`, `commit: false`, `mode: r` for the +reflect method, the authored `mode` value (`rw` by default) for the alignment method, and +the global `memory_use: false` under both methods — participation is per-stage opt-in); +`path` is the fixed prefix `.goga/memory` plus an optional authored +suffix; the goga-side `method` key (reflect | alignment) is never written to the +flow-file. ## Integration pattern — running afm in a container diff --git a/.goga/usages/cooks/click.md b/.goga/usages/cooks/click.md index b6c80777..96c5eb66 100644 --- a/.goga/usages/cooks/click.md +++ b/.goga/usages/cooks/click.md @@ -84,6 +84,20 @@ app.add_command(command) - Use only for obvious positional data (paths, file names) - If the meaning of a parameter is not obvious — use `option` +### Option — repeatable parameters + +```python +@click.option('--status', '-s', multiple=True, help='Фильтр по статусу (повторяемый)') +def status(status: tuple[str, ...]) -> None: + for name in status: + ... +``` + +- An option with `multiple=True` may be passed several times; click collects the values into a tuple in passing order (`-s defined -s discovered` → `("defined", "discovered")`) +- The parameter value is a tuple; no passes yield an empty tuple `()`, not `None` +- Check "option not passed" by testing the tuple for emptiness, never against `None` +- A long and a short form on one option (`--status`/`-s`) behave like any other option + ## Passing State Between Commands To pass data from the root group to subcommands, use `ctx.obj`: @@ -162,6 +176,11 @@ sys.exit(1) - Output errors via `click.secho(..., err=True)` or `raise click.ClickException` - Do not use `print()` directly +## Colored Output and NO_COLOR + +- `click.echo` / `click.secho` strip ANSI codes themselves when the output stream is not a TTY (a pipe or redirect receives plain text) — no manual `isatty()` check is needed for that case +- click does NOT honor the `NO_COLOR` environment variable: check it explicitly and, when it is set to a non-empty value, do not pass a color to `secho` (the no-color.org convention: present and non-empty — color is disabled always, even in a TTY) + ## Testing CLI Click provides the `CliRunner` utility for testing: @@ -181,6 +200,21 @@ def test_hello(): - Check `result.exit_code` and `result.output` - For user input: `runner.invoke(cli, input='yes\n')` +## Interactive Multi-Line Entry + +A multi-line text (a topic todo) is collected or edited in an **external +editor**, not through a click prompt cycle — the `editor` practice +(`.goga/usages/cooks/editor.md`) owns the entry protocol: the +`$VISUAL` → `$EDITOR` → `vi` editor chain, the temporary file, the +cancellation rule (an empty or unchanged file continues as without the +entry), the non-TTY clean error, and the `$EDITOR` test mock. The former +prompt cycle with its lone `.` line and Ctrl+D terminators is abolished. + +Within this practice, click still owns the surrounding CLI moments: the +option surface that triggers or bypasses the entry (a plain value option +that passes the text directly, a boolean flag that requests the entry), +the confirmations, and the clean error rendering. + ## Anti-patterns - Do not use `argparse` together with `click` in the same application diff --git a/.goga/usages/cooks/editor.md b/.goga/usages/cooks/editor.md new file mode 100644 index 00000000..036dc198 --- /dev/null +++ b/.goga/usages/cooks/editor.md @@ -0,0 +1,95 @@ +# Multi-Line Text Entry with an External Editor + +## Library + +**External editor** — the user's editor resolved git-style: `$VISUAL` → `$EDITOR` → the +system default `vi`. No new Python dependency; the editor is an external process. + +**IMPORTANT** — no editor fields exist in the project or home configuration; the +environment chain plus the default is the whole resolution. + +## Purpose + +Interactive entering and editing of a multi-line text (a topic todo) with full editing +capabilities — erasing any line, moving the cursor — which a prompt cycle cannot offer. +Use this practice for every interactive moment that collects or edits a multi-line text. +The practice replaces the former prompt-cycle entry (the lone `.` line and Ctrl+D +terminators are abolished). + +## Entry Protocol + +1. Resolve the editor: `$VISUAL` first, then `$EDITOR`, otherwise `vi`. +2. Create a temporary file outside the topic directory — empty for a fresh text, or + prefilled with the existing content when an existing text is edited. +3. Print the hint to the terminal **before** launching the editor — an empty file means + cancellation. No hint comments inside the file itself. +4. Launch the editor on the temporary file. Saving and exiting the editor completes the + entry. +5. Read the file back after the editor exits. + +## Cancellation + +An empty or unchanged file means the entry did not happen — execution continues as +without the entry. When an existing target file is edited, cancellation leaves it +untouched. The emptiness check runs on the saved content; a text of only blank lines +cancels the entry the same way. + +## Errors + +- A non-TTY when the entry is requested is a clean error raised **before any mutation** + (except where a command explicitly documents a silent skip of the flag instead). +- A non-zero editor exit code and Ctrl+C are clean errors without mutations. +- A missing editor binary surfaces as a clean error, not a traceback. + +## Writing the Result + +The saved text is written to the target file as entered plus a single trailing newline, +encoded UTF-8. Empty lines inside the text stay as entered. The temporary file is +removed after the session (its exact placement and cleanup details belong to the +implementation). + +## Testing + +Mock `$EDITOR` with a script that writes into the file (or leaves it, or clears it, or +exits non-zero) to model every outcome: save, cancellation, failure. Mock the TTY +detection for the non-interactive error. A real editor is never launched in tests. + +## Launch Mechanism + +The editor process is launched through the editor facility of the click +library (already a project dependency): the facility resolves the +editor git-style ($VISUAL, then $EDITOR, then the system default vi), +creates and reads back the temporary file, and maps a failed editor run +to a clean error. The hint before the launch, the cancellation check +(blank or unchanged saved content), and the non-TTY detection belong to +the calling code. + +```python +import sys + +import click + + +def enter_text(initial: str | None = None) -> str | None: + if not sys.stdin.isatty(): + raise click.ClickException("the entry needs an interactive terminal") + click.echo("Enter the text. An empty or unchanged file cancels the entry.") + start = initial or "" + if start and not start.endswith("\n"): # the click facility prefills + start += "\n" # with a trailing newline + saved = click.edit(text=start) + if saved is None or not saved.strip() or saved == start: + return None + return saved +``` + +The equality check compares against the **normalized prefill**, not the raw +`initial`: the click facility appends a missing trailing newline when it +writes the temporary file, so an unchanged save of a text without one comes +back as `initial + "\n"` — comparing to the raw value would treat the +unchanged save as a change. + +In tests mock the editor with a script that writes into the file (or +leaves it, or clears it, or exits non-zero) via the `$EDITOR` +environment variable, and mock the TTY detection for the +non-interactive error. A real editor is never launched in tests. diff --git a/.goga/usages/cooks/ralphex.md b/.goga/usages/cooks/ralphex.md index a3f6e157..ff67ffec 100644 --- a/.goga/usages/cooks/ralphex.md +++ b/.goga/usages/cooks/ralphex.md @@ -106,6 +106,11 @@ ralphex will find the first incomplete task (`- [ ]`) and continue from there. | `-p, --port` | Web dashboard port (with `--serve`) | 8080 | | `-d, --debug` | Debug output | false | | `--no-color` | Disable colored output | false | +| `-b, --base-ref` | Override default branch for review diffs | — | +| `--review-patience` | Stop external review after N unchanged rounds | 0 (disabled) | +| `--session-timeout` | Session timeout (Go duration) | disabled | +| `--idle-timeout` | Idle timeout (Go duration) | disabled | +| `--wait` | Rate-limit retry wait (Go duration) | — | Note: `--review` ignores `--worktree` — the review runs against the current branch/repository state, not the worktree branch. @@ -113,6 +118,13 @@ branch/repository state, not the worktree branch. Note: `--tasks-only` skips every review phase — the internal review agents and the external codex review alike; `codex_enabled` has no effect in that mode. +Note: `--base-ref` overrides ralphex's default-branch detection for review +diffs; the value is a branch name or a commit hash. ralphex auto-detects the +default branch via the remote HEAD (fallbacks: main/master/trunk/develop) — +when detection fails, review agents lose the diff scope. goga threads +`--base-ref` from its `build.review_executor.base_ref` config key / CLI +`--base-ref` onto review-carrying passes only. + ## Configuration ralphex uses `~/.config/ralphex/` (global) or `.ralphex/` in the project root (local). diff --git a/.goga/workflows/bugfix.yml b/.goga/workflows/bugfix.yml index 8af3d51b..a0949a17 100644 --- a/.goga/workflows/bugfix.yml +++ b/.goga/workflows/bugfix.yml @@ -1,6 +1,9 @@ prompt: | Answer (feedbacks, proposes, questions and etc) in Russian language. +memory: + max_rules: 15 + stages: hotfix: prompt: | @@ -12,3 +15,5 @@ stages: Constraints: - Don't implementation without approve from user - Don't research before you receive the task from user + reflect: + file: code-design.md diff --git a/.goga/workflows/development.yml b/.goga/workflows/development.yml index fd2718af..6bd0795d 100644 --- a/.goga/workflows/development.yml +++ b/.goga/workflows/development.yml @@ -1,6 +1,10 @@ prompt: | Answer (feedbacks, proposes, questions and etc) in Russian language. +memory: + max_rules: 15 + commit: true + stages: brainstorm: prompt: | @@ -11,20 +15,28 @@ stages: - Every usage file is connected through Imports and referenced in annotations - Usage files are self-contained - Footer Description describes the responsibility zone + - Before deciding how to do something, reflect on the memory file. Constraints: - Annotations must not reference previous functionality - Annotations does not contains implementation details - Annotations must not use "X from Imports" phrasing - Footer Description does not contains details + reflect: + file: architecture.md architecture-review: approve: auto apply-architecture: approve: auto code-design: approve: auto + reflect: + file: code-design.md + mode: r design-review: approve: auto + reflect: + file: code-design.md coding-plan: approve: auto plan-review: @@ -38,5 +50,5 @@ extend: after: - commit-changes timeout: "8h" - script: python3 -m goga.build docs/plans/$(git branch --show-current).md + script: python3 -m goga.build "$(python3 -m goga history path -f plan.md)" after_script: rm -rf .ralphex diff --git a/.goga/workflows/review.yml b/.goga/workflows/review.yml index c74b57c3..4dd6066c 100644 --- a/.goga/workflows/review.yml +++ b/.goga/workflows/review.yml @@ -15,3 +15,25 @@ stages: Requirements: - Create venv outside the project, in /opt/goga + +extend: + update-docs: + communication: true + before: + - code-review + - contracts-review + - documentation-review + after: + - discovery-scope + skills: + - goga-tool + prompt: | + ARGUMENTS="mkdocs" + + Requirements: + - Use `/tmp/changes.txt` for discover changelog + - Commit changes after update docs + - Ask the user if the decision to create a new documentation section or change structure is ambiguous + + Constraints: + - Capture only the current state of the product, do not capture changes in the changelog format diff --git a/.goga/workflows/sync.yml b/.goga/workflows/sync.yml new file mode 100644 index 00000000..9fc61e96 --- /dev/null +++ b/.goga/workflows/sync.yml @@ -0,0 +1,2 @@ +prompt: | + Answer (feedbacks, proposes, questions and etc) in Russian language. diff --git a/Dockerfile b/Dockerfile index 1d1c526c..e820c358 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG AFM_VERSION=0.5.51 +ARG AFM_VERSION=0.5.67 ARG RALPHEX_VERSION=1.6 ARG PYTHON_VERSION=3.12 ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 @@ -57,10 +57,10 @@ ENV GOGA_DOCKER=1 ENV RALPHEX_DOCKER=1 ENV AFM_IN_DOCKER=1 -WORKDIR /workspace - USER goga +WORKDIR /workspace + RUN goga connect claude codex opencode qwen cursor ENTRYPOINT ["goga"] diff --git a/README.md b/README.md index 483bf899..3d25e996 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ AI development without a framework collapses into uncoordinated agent runs — t -[Documentation](https://qarium.github.io/goga/) · [Getting Started](https://qarium.github.io/goga/getting-started/) · [Pipelines](https://qarium.github.io/goga/pipelines/) · [Tools](https://qarium.github.io/goga/tools/) · [Configuration](https://qarium.github.io/goga/configuration/) +[Documentation](https://qarium.github.io/goga/) · [Getting Started](https://qarium.github.io/goga/getting-started/) · [Pipelines](https://qarium.github.io/goga/features/pipelines/) · [Tools](https://qarium.github.io/goga/features/tools/) · [Configuration](https://qarium.github.io/goga/configuration/) @@ -89,7 +89,7 @@ To upgrade goga later and re-sync all connected agents, use: goga upgrade ``` -To stay within your current version line while upgrading, use `goga upgrade --patch` (latest patch of the installed minor line) or `goga upgrade --minor` (latest release of the installed major line). See [`goga upgrade`](https://qarium.github.io/goga/cli/upgrade/) for the full surface. +To stay within your current version line while upgrading, use `goga upgrade --patch` (latest patch of the installed minor line) or `goga upgrade --minor` (latest release of the installed major line). See [`goga upgrade`](https://qarium.github.io/goga/features/upgrade/cli/) for the full surface. ## Quick start @@ -101,7 +101,7 @@ Start a new project from scratch and ship your first piece of work end-to-end. goga init ``` -You can also start from a [copier](https://copier.readthedocs.io/) template (`goga init `, optionally pinned with `#ref` or `--ref`), and later migrate a scaffolded project with `goga init --upgrade`. See [`goga init`](https://qarium.github.io/goga/cli/init/) for the full surface. +You can also start from a [copier](https://copier.readthedocs.io/) template (`goga init `, optionally pinned with `#ref` or `--ref`), and later migrate a scaffolded project with `goga init --upgrade`. See [`goga init`](https://qarium.github.io/goga/features/init/cli/) for the full surface. **2. Open your agent** — launch the agent you connected via `goga connect` (e.g., Claude Code) in the project directory. All `goga-` skills are now available. @@ -116,7 +116,7 @@ goga pipeline review # scoped review of code, contracts, docs, then lint goga pipeline sync # sync specifications & tests with the code after changes ``` -Each pipeline is a flat YAML file describing the stages; layer project-specific behavior on top via an optional [workflow](https://qarium.github.io/goga/pipelines/workflows/) file (per-stage agent, additional skills, prompt context, loop expansion, auto-approval, manual stage launch, stage skipping, new stages). +Each pipeline is a flat YAML file describing the stages; layer project-specific behavior on top via an optional [workflow](https://qarium.github.io/goga/features/pipelines/workflows/) file (per-stage agent, additional skills, prompt context, loop expansion, auto-approval, manual stage launch, stage skipping, note buttons, new stages, project-memory participation). **4. Drive the cycle by hand (optional)** — if you want explicit control over each step instead of running a full pipeline, formulate the task and step through each command manually: @@ -128,7 +128,7 @@ Each pipeline is a flat YAML file describing the stages; layer project-specific propose → brainstorm → apply → design → plan → goga build → change → accept ``` -The slash-command form `/goga:` works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](https://qarium.github.io/goga/cli/connect/)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-propose` (Codex uses the `$` prefix — `$goga-propose`). Reviews are optional at every stage. +The slash-command form `/goga:` works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](https://qarium.github.io/goga/features/connect/cli/)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-propose` (Codex uses the `$` prefix — `$goga-propose`). Reviews are optional at every stage. **5. Visualize the result** — once `apply` has produced cells on disk, inspect the architecture: @@ -136,6 +136,32 @@ The slash-command form `/goga:` works in agents that consume the goga c goga schema | goga tool viewer ``` +## Topics + +Work is organized as **topics** — one directory per piece of work under `.goga/history///`, each usually living on its own git branch. The `goga topics` command group manages them: + +```bash +goga topics board # the board: every topic of the year across branches +goga topics board --remote # same board over remote-tracking refs +goga topics board --info # the board with the todo column (the todo summary of todo.md) +goga topics create feat/x --from-current # fresh work off the current HEAD: the branch verbatim + its topic committed, you stay on your branch +goga topics create feat/x --from-current -t "Payment retry" # same; the todo becomes the branch's todo.md commit (status: todo) +goga topics create feat/x --from-current -s # same, but switch to the fresh branch; on a terminal the todo entry opens in your $EDITOR +goga topics create feat/x --from-current -p -t "Payment retry" # same as the default, plus pushed to origin +goga topics switch feat-x # onto the branch hosting that work (branch, slug, or prefix) +goga topics switch feat-x --todo # same, then edit the topic's todo.md in your $EDITOR +goga topics delete feat-x # delete the branch, its origin twin, and the directory +goga topics --year 2025 board # the board of an explicit year +``` + +Every `create` needs a base: `--base-ref`, or `topics.base_ref` in `.goga/config.yml`, or the current HEAD under `--from-current`. The default creation quarantines the topic into the branch — one commit carrying the topic's `todo.md` on top of the base — while you stay on your branch; the todo is required there, so with no `-t` given a terminal opens the external editor for the todo, and once a todo is resolved the command asks on a terminal whether to publish. `-s`/`--switch` checks out the fresh branch instead — the topic directory and `todo.md` land in the working copy uncommitted, and the todo is optional. `--publish`/`-p` is the fast mode: it builds the branch off the resolved base with a single `todo.md` commit and pushes it to `origin` without switching — your working copy, index, and HEAD stay untouched, and a failed push rolls the branch back. See [`goga topics`](https://qarium.github.io/goga/features/topics/cli/). + +The board is a three-column table — topic, branch, statuses, plus a todo column under `--info` — with `*` marking the current branch and a local branch absorbing its remote twin. Each topic carries its **maximal statuses** in scale order: `empty → todo → defined → discovered → backlog → designed → specified → planned → done`, deepening as `todo.md`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. A topic can carry several statuses at once (`goga history status` prints them; `-s` filters by any of them). + +Topics no branch hosts anymore are orphans — [`goga history`](https://qarium.github.io/goga/features/history/cli/) `prune --dry-run` lists the orphans of a year, and `goga history -y prune` deletes them (the year is the group's `-y`/`--year` option, given once before the subcommand; irreversibly: the history tree is not in git). + +To resume work inside a pipeline, pass the identifier to the run — `goga pipeline development -t feat/x` switches to the hosting branch first (creating a local branch from its remote-tracking ref when needed) and is an idempotent no-op when you are already on it; adding `--todo` opens the topic's `todo.md` in your editor after the switch. Fresh work is better started with `goga topics create` — it takes an explicit base, todo, and publication; a pipeline `-t` creates from the current HEAD when nothing hosts the identifier. + ## Pipelines A **pipeline** is a declarative scenario of stages an agent walks through to deliver a piece of work — propose, review, brainstorm, apply, design, plan, build, change, accept. A pipeline-file does not depend on any concrete agent: claude, codex, qwen, opencode, or any other installed wrapper can execute it. Stages with `communication: true` pause the run and ask for human input; without it they run autonomously. @@ -151,7 +177,9 @@ description: End-to-end feature development title: "Create the task from a user propose" communication: true prompt: | - Save the task file as `.md` + Save the task file as `.goga/history///task.md` + (`` = current year, `YYYY`; `` = lowercase kebab-case slug + of the current git branch name; create the directory lazily) skills: - goga-propose @@ -183,6 +211,7 @@ Pipelines are resolved from `/.goga/pipelines/` (project) and `~/.goga/pipe ```bash goga pipeline development # run the development cycle (opens with brainstorm) +goga pipeline development -t feat/x # first switch to the branch hosting this work, then run goga pipeline refinement -s discover # shorter run: skip technical discovery goga pipeline development -p 4 # cap parallelism (subject to the pipeline's dependency rules) goga pipeline development --clean # wipe persistent state for a fresh run @@ -200,14 +229,12 @@ A running pipeline executes inside a Docker container, where its flows, run-stat ### Workflows — configure and extend a pipeline -A **workflow-file** (`.goga/workflows/.yml`) configures and extends a compiled pipeline at run time, without touching the pipeline-file. Six levers, each with a short example. +A **workflow-file** (`.goga/workflows/.yml`) configures and extends a compiled pipeline at run time, without touching the pipeline-file. Eight levers, each with a short example. **`agent` — hire a different agent per stage.** Authoring on `codex`, reviews on `claude`, no pipeline duplication: ```yaml stages: - propose: - agent: codex brainstorm: agent: codex architecture-review: @@ -236,8 +263,8 @@ stages: ```yaml stages: - deploy: - manual: true # the pipeline pauses before deploy until launched manually + accept-result: + manual: true # the pipeline pauses before accept-result until launched manually ``` **`skills` — add skills to a stage.** Merged with the pipeline stage's own skills (pipeline-first, deduplicated by value): @@ -264,7 +291,28 @@ stages: - Do not build architecture in the task. ``` -Additionally: `skip: true` removes a stage with transparent reconnection of dependents, and `extend:` adds brand-new stages with `before`/`after` positioning (a new stage's own launch mode is authored in its body via `trigger: manual`). The full model is in the [Workflows](https://qarium.github.io/goga/pipelines/workflows/) documentation. +**`notes` — attach note buttons to a stage.** A map of note name → prompt text, compiled verbatim into the stage's `buttons` field: + +```yaml +stages: + plan-review: + notes: + fix: Fix the failure and continue +``` + +**`memory` — wire stages into project memory.** A top-level block plus per-stage instructions; the block is emitted only when at least one stage participates: + +```yaml +memory: + method: reflect # or: alignment + max_rules: 40 +stages: + brainstorm: + reflect: # which memory file the stage reflects into + file: shared.md +``` + +Additionally: `skip: true` removes a stage with transparent reconnection of dependents, and `extend:` adds brand-new stages with `before`/`after` positioning (a new stage's own launch mode is authored in its body via `trigger: manual`). Names under `stages:` must name stages of the target pipeline — `propose` exists only in `refinement`, `brainstorm` only in `development`; brand-new stages come via `extend:`. The full model is in the [Workflows](https://qarium.github.io/goga/features/pipelines/workflows/) documentation. Workflow memory requires afm 0.5.60+ (the shipped image carries it). Run with a workflow: @@ -274,7 +322,7 @@ goga pipeline development --workflow custom # explicit goga pipeline development --no-workflow # disable workflow application entirely ``` -Read the full functional model in the [Pipelines](https://qarium.github.io/goga/pipelines/) section of the docs. +Read the full functional model in the [Pipelines](https://qarium.github.io/goga/features/pipelines/) section of the docs. ## Tools @@ -296,17 +344,20 @@ goga install # Install a tool from a local source directory (no PyPI lookup) goga install --local + +# Same, naming the tool whose post-install hook runs +goga install --local : ``` -After installing, connect the tool to your agent (only required the first time, or to connect a new agent — `goga install` re-syncs already-connected agents automatically): +After a successful pip, `goga install` runs each freshly installed tool's optional post-install hook (a callable `install` in its facade — skipped quietly when absent), then re-syncs every already-connected agent automatically. Connect a new agent at any time: ```bash goga connect ``` -Pass `goga install --no-connect` to opt out of post-install activation (CI/Docker escape-hatch). Pass `goga install --sudo` for system-Python installs requiring root. +Pass `goga install --no-connect` to opt out of the post-install agent re-sync (CI/Docker escape-hatch; the post-install hooks still run). Pass `goga install --sudo` for system-Python installs requiring root. -See [`goga install`](https://qarium.github.io/goga/cli/install/) for the full version-grammar rules and single/bulk/empty/local semantics. +See [`goga install`](https://qarium.github.io/goga/features/install/cli/) for the full version-grammar rules and single/bulk/empty/local semantics. ### Removing a tool @@ -329,7 +380,7 @@ goga uninstall --user alice After a successful pip uninstall, every connected agent is re-synced: the removed tool's skills and pipelines disappear from `~/.goga/` and from each agent's symlink tree. A tool removed by hand with plain pip leaves those artifacts behind until the next re-sync. -See [`goga uninstall`](https://qarium.github.io/goga/cli/uninstall/) for the full confirmation, sudo/user, and exit-code semantics. +See [`goga uninstall`](https://qarium.github.io/goga/features/install/uninstall/) for the full confirmation, sudo/user, and exit-code semantics. ### Using a tool @@ -355,12 +406,14 @@ The following tools ship with goga out of the box — no separate install requir ### Packaging your own tool -Minimal layout, illustrated by a tool named `acme` that ships four subcommands — `explore`, `propose`, `apply`, `archive` — and one pipeline-file, with no top-level dispatcher skill: +Minimal layout, illustrated by a tool named `acme` that ships four subcommands — `explore`, `propose`, `apply`, `archive` — and one pipeline-file: ``` goga_tool_acme/ -├── __init__.py # main(argv: list[str]) — CLI entry point +├── __init__.py # main(argv: list[str]) — CLI entry; optional install()/register_hooks() ├── skills/ +│ ├── acme/ +│ │ └── SKILL.md # goga-tool-acme — the entry point │ ├── acme-explore/ │ │ └── SKILL.md # goga-tool-acme-explore │ ├── acme-propose/ @@ -376,10 +429,25 @@ goga_tool_acme/ A valid tool **must**: - Be named with the `goga_tool_` prefix (PyPI publication under `goga-tool-`) -- Contain a `skills/` directory with at least one skill (each skill directory has a `SKILL.md`) -- Expose a `main(argv: list[str])` function for CLI execution (optionally declaring a keyword-capable `ast` parameter to receive the project AST) +- Contain the entry-point skill `skills//SKILL.md` — `goga connect` skips the whole package with a warning when it is missing (every skill directory carries a `SKILL.md`) +- Expose a `main(argv: list[str])` function for CLI execution - A `pipelines/` directory is **optional**; when present, its flat `*.yml` files are copied into `~/.goga/pipelines/` at `goga connect` time, namespaced as `:.yml` +A tool **may** additionally expose an `install(user: str | None = None)` callable in its facade package: `goga install` calls it after a successful pip, passing the initiating user (`SUDO_USER` when goga itself runs under sudo, else the current OS user) only when the parameter is declared keyword-capable. A missing or non-callable `install` is skipped quietly. + +A tool **may** also expose a `register_hooks(hooks)` callable to extend goga domains with its own hooks — today, the topic status scale. goga calls it when a command first reaches a hook checkpoint that needs statuses, or when you inspect the registry with `goga hooks`; commands that use no hooks never call it: + +```python +def register_hooks(hooks): + hooks.subscribe("statuses", "register_statuses", "published", register_published) + + +def register_published(context): + context.register("published", "mkdocs/published.md", after="planned") +``` + +The hook receives the delivered status registry through `context` — read and call freely, attribute assignment is blocked. The name is shown qualified as `.` (here `mkdocs.published`); the tool identity is the package name with the `goga_tool_` prefix dropped and underscores turned into hyphens, so `goga_tool_hello_world` registers `hello-world.*`. The filepath is relative to the topic directory (nested paths allowed), and `before=`/`after=` anchor the entry to an existing scale entry — at least one anchor is required, both define a range. Built-in entries are immutable. A bad registration — an unknown anchor, an invalid range, or a crashed hook — is skipped with a warning on stderr and never aborts the command; only a package that fails to import is fatal. Run [`goga hooks`](https://qarium.github.io/goga/features/hooks/cli/) to inspect what is registered. The removed `register_topic_statuses(statuses)` callback is no longer called — a package still carrying it loses its statuses silently after the update. + After publication, install into any project: ```bash @@ -389,7 +457,7 @@ goga pipeline acme:spec # namespaced pipeline from the tool The subcommands become ordinary agent skills — `goga-tool-acme-explore`, `goga-tool-acme-propose`, `goga-tool-acme-apply`, `goga-tool-acme-archive` — that can be invoked directly (`/goga:tool acme explore`, `goga-tool-acme-explore`, or `$goga-tool-acme-explore` in Codex) or merged into any stage of any pipeline via `skills:` in a workflow-file. The `acme` cycle `explore → propose → apply → archive` can be run end-to-end through `acme:spec`, woven stage-by-stage into the SDD cycle, or composed into a custom pipeline where `acme-propose` runs next to `goga-brainstorm`. -The entry point may optionally declare a keyword-capable `ast` parameter to receive the project AST (loaded lazily from the current project root, only when declared). A tool that does not need the AST keeps the minimal `main(argv)` form and the AST is never built. See [`goga tool`](https://qarium.github.io/goga/cli/tool/) for the entry-point forms and opt-in rules. +The entry point may optionally declare a keyword-capable `ast` parameter to receive the project AST (loaded lazily from the current project root, only when declared). A tool that does not need the AST keeps the minimal `main(argv)` form and the AST is never built. See [`goga tool`](https://qarium.github.io/goga/features/tools/cli/) for the entry-point forms and opt-in rules. ### Skill naming @@ -404,7 +472,7 @@ When `goga connect` installs a tool, the prefix `goga-tool--` is adde Rules: - Use lowercase with hyphens as separators -- When a top-level dispatcher skill is wanted, name its directory exactly `` — it becomes the entry point invoked by `/goga:tool ` (or `goga-tool` / `$goga-tool` in agents without slash-command support). A tool that exposes only subcommands (like `acme` above) skips this directory. +- The entry-point skill directory is named exactly `` — it becomes the skill invoked by `/goga:tool ` (or `goga-tool` / `$goga-tool` in agents without slash-command support). It is required: a package without `skills//SKILL.md` is skipped by `goga connect` with a warning. - Name sub-skills descriptively using the `-` pattern (e.g., `mkdocs-discovery`, `mkdocs-validator`) ### Pipeline namespacing @@ -416,9 +484,9 @@ Tool pipelines are namespaced on install. A file `.yml` in a tool's `pipel | Internal goga source (`goga/assets/pipelines/`) | `development.yml` (un-prefixed) | `goga pipeline development` | | Tool package `goga_tool_acme/pipelines/deploy.yml` | `acme:deploy.yml` | `goga pipeline acme:deploy` | -Namespacing structurally prevents collisions — between a tool pipeline and an internal-source pipeline, and between two tools shipping the same name. See [Shipped Pipelines](https://qarium.github.io/goga/pipelines/shipped/) for the full installation algorithm. +Namespacing structurally prevents collisions — between a tool pipeline and an internal-source pipeline, and between two tools shipping the same name. See [Shipped Pipelines](https://qarium.github.io/goga/features/pipelines/shipped/) for the full installation algorithm. -Read the full Tools model in the [Tools](https://qarium.github.io/goga/tools/) section of the docs. +Read the full Tools model in the [Tools](https://qarium.github.io/goga/features/tools/) section of the docs. ## SDD — the reference cycle @@ -562,14 +630,14 @@ stages: approve: auto ``` -These are not special "SDD extension points" — they are exactly the same workflow mechanisms from the Pipelines section, applied to the SDD cycle. Combining tools and workflows, SDD can be compressed to `propose → accept` for prototypes or expanded with threat-modelling, security review, and compliance gates for production. Read the full functional model in the [Workflow](https://qarium.github.io/goga/pipelines/workflows/) section of the docs. +These are not special "SDD extension points" — they are exactly the same workflow mechanisms from the Pipelines section, applied to the SDD cycle. Combining tools and workflows, SDD can be compressed to `propose → accept` for prototypes or expanded with threat-modelling, security review, and compliance gates for production. Read the full functional model in the [Workflows](https://qarium.github.io/goga/features/pipelines/workflows/) section of the docs. ## Build `goga build` is a separate service that materializes a plan into code. Pipelines produce plans; Build executes them — and neither side is a special case of the other. A plan is handed to a ralph-loop running inside an isolated Docker container, which reads the plan, executes each task in sequence (declaration → contract tests → implementation → interface verification → logic tests → lint → review → approval), and writes the implementation into the project tree. `CODEMANIFEST` files stay **read-only** throughout — the contract is the source of truth, the build produces code that satisfies it. ```bash -goga build docs/plans/.md +goga build .goga/history///plan.md ``` The host side assembles the environment and launches the container; the in-container process then guards its environment, prepares the loop's working directory, and runs the loop with the plan as input. Credential files for `claude`, `codex`, and `opencode` are detected on the host and bind-mounted read-only into the container automatically (no flag), so the agent executing the plan runs with your live credentials. @@ -583,11 +651,20 @@ goga build plan.md -e ENV_VAR=value # forward an extra env var into the co goga build plan.md --skip-review # run tasks only, skip the review phase ``` -The review phase is configurable beyond the on/off flag: a `build.review_executor` section in `.goga/config.yml` can hand review to a different agent (`agent: codex` runs a second, review-only pass on the codex wrapper), skip it by default (`skip: true` — `--no-skip-review` forces the full cycle), select the reviewer composition (`roles: [quality, testing]`), and layer environment variables onto the review pass alone (`env: {ANTHROPIC_MODEL: reviewer}` — the variables overlay the container environment for the review subprocess only; the tasks pass never sees them, the values never reach logs or dry-run output, and like a differing agent a non-empty `env` forces a two-pass run, so it cannot be combined with a worktree). After a successful run the plan file itself moves to `docs/plans/completed/`. +The review phase is configurable beyond the on/off flag through a `build.review_executor` section in `.goga/config.yml`: + +- hand review to a different agent (`agent: codex` runs a second, review-only pass on the codex wrapper); +- skip it by default (`skip: true` — `--no-skip-review` forces the full cycle); +- select the reviewer composition (`roles: [quality, testing]`); +- layer environment variables onto the review pass alone (`env: {ANTHROPIC_MODEL: reviewer}` — the variables overlay the container environment for the review subprocess only; the tasks pass never sees them, the values never reach logs or dry-run output, and like a differing agent a non-empty `env` forces a two-pass run, so it cannot be combined with a worktree); +- bound the review diff to an explicit base (`base_ref: origin/main` — a branch name or commit hash that overrides ralphex's default-branch detection; `--base-ref` on the command line wins); +- stop the external review after N unchanged rounds (`patience: 3`, or `--review-patience` — the setting moved from the top-level `build.review_patience` key, which is no longer parsed). + +Both review bounds apply to review-carrying passes only: the single full-cycle pass, or the review pass of a two-pass run. After a successful run the plan file itself moves to `completed/` inside its own topic directory (`.goga/history///completed/`). A running build executes inside a Docker container, where its run-state and logs are written to a persistent host directory and survive across runs of the same project on the same branch — so an interrupted build can be resumed. Pass `--clean` (or `-c`) to wipe that state before launch for a fresh run. After the build, test the implementation manually. -See [`goga build`](https://qarium.github.io/goga/cli/build/) for the full CLI reference, configuration, and exit codes. +See [`goga build`](https://qarium.github.io/goga/features/build/cli/) for the full CLI reference, configuration, and exit codes. ## Documentation diff --git a/docs/cli/index.md b/docs/cli/index.md index 4f365149..389a45a3 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -2,6 +2,8 @@ Goga is a command-line tool built with [Click](https://click.palletsprojects.com/) for validating and managing CODEMANIFEST-based projects. +This page is the **command cross-road**: every command of the root `goga` group, mapped to the functional domain that owns it. The full reference of each command — synopsis, options, behavior, exit codes — lives in its domain's **CLI** page under [Features](../features/index.md). + ## Installation ```bash @@ -22,21 +24,24 @@ python -m goga --help ## Commands -| Command | Description | -|---|---| -| [`goga init`](init.md) | Interactive project initialization | -| [`goga install`](install.md) | Install goga-tool packages into the current interpreter and re-sync connected agents | -| [`goga uninstall`](uninstall.md) | Remove a goga-tool package from the current interpreter and re-sync connected agents | -| [`goga lint`](lint.md) | Validate CODEMANIFEST files | -| [`goga build`](build.md) | Execute build plan via a ralph-loop | -| [`goga contract`](contract.md) | Compare CODEMANIFEST with implementation | -| [`goga config`](config.md) | Display configuration values | -| [`goga schema`](schema.md) | Generate JSON schema from project cells | -| [`goga connect`](connect.md) | Install goga skills for AI agents | -| [`goga upgrade`](upgrade.md) | Upgrade goga and re-sync connected agents | -| [`goga usages`](usages.md) | Sync cell-level usages from declared git dependencies and check their status against the remote | -| [`goga pipeline`](pipeline.md) | Run a goga pipeline, or inspect the available ones (`--list`, `--info`) | -| [`goga tool`](tool.md) | Dynamic tool package invocation | +| Command | Domain | Description | +|---|---|---| +| [`goga init`](../features/init/cli.md) | [Init](../features/init/index.md) | Interactive project initialization | +| [`goga install`](../features/install/cli.md) | [Install](../features/install/index.md) | Install goga-tool packages into the current interpreter and re-sync connected agents | +| [`goga uninstall`](../features/install/uninstall.md) | [Install](../features/install/index.md) | Remove a goga-tool package from the current interpreter and re-sync connected agents | +| [`goga lint`](../features/lint/cli.md) | [Lint](../features/lint/index.md) | Validate CODEMANIFEST files | +| [`goga build`](../features/build/cli.md) | [Build](../features/build/index.md) | Execute build plan via a ralph-loop | +| [`goga contract`](../features/contract/cli.md) | [Contract](../features/contract/index.md) | Compare CODEMANIFEST with implementation | +| [`goga config`](../configuration/cli.md) | [Configuration](../configuration/index.md) | Display configuration values | +| [`goga schema`](../features/schema/cli.md) | [Schema](../features/schema/index.md) | Generate JSON schema from project cells | +| [`goga connect`](../features/connect/cli.md) | [Connect](../features/connect/index.md) | Install goga skills for AI agents | +| [`goga upgrade`](../features/upgrade/cli.md) | [Upgrade](../features/upgrade/index.md) | Upgrade goga and re-sync connected agents | +| [`goga usages`](../features/usages/cli.md) | [Usages](../features/usages/index.md) | Sync cell-level usages from declared git dependencies and check their status against the remote | +| [`goga pipeline`](../features/pipelines/cli.md) | [Pipelines](../features/pipelines/index.md) | Run a goga pipeline, or inspect the available ones (`--list`, `--info`) | +| [`goga history`](../features/history/cli.md) | [History](../features/history/index.md) | Work with the `.goga/history/` tree (`list`, `status`, `path`, `ensure`, `prune`) | +| [`goga topics`](../features/topics/cli.md) | [Topics](../features/topics/index.md) | Work with the topics of one year (`board`, `create`, `switch`, `delete`) | +| [`goga tool`](../features/tools/cli.md) | [Tools](../features/tools/index.md) | Dynamic tool package invocation | +| [`goga hooks`](../features/hooks/cli.md) | [Hooks](../features/hooks/index.md) | Inspect the hooks registered by installed tool packages | ## Global Options @@ -69,4 +74,4 @@ The option belongs to the root group and is processed eagerly, before any subcom When the installed version cannot be determined (goga is not installed for the current interpreter, or its metadata is broken), the command fails cleanly: a one-line `Error: cannot determine the installed goga version (...)` message on stderr, exit code `1`, no traceback. -The flag takes no part in the host–image version check performed before container launches — that check is part of [`goga build`](build.md) and [`goga pipeline`](pipeline.md). +The flag takes no part in the host–image version check performed before container launches — that check is part of [`goga build`](../features/build/cli.md) and [`goga pipeline`](../features/pipelines/cli.md). diff --git a/docs/cli/install.md b/docs/cli/install.md deleted file mode 100644 index b7eeb65d..00000000 --- a/docs/cli/install.md +++ /dev/null @@ -1,142 +0,0 @@ -# goga install - -`goga install` adds goga-tool packages into the **current runtime interpreter** — the exact Python that runs goga. It targets the running interpreter's pip directly, so the install lands in the correct environment regardless of how goga was deployed (pipx venv, system Python, or any other). - -After a successful pip in single, local, or bulk mode, the command **activates** every agent already recorded in `~/.goga/connect.yml` (re-syncing each with its persisted `force_overwrite`) so the freshly installed tool's skills and pipelines appear in `~/.goga/` and in each connected agent's symlink tree. Pass `--no-connect` to skip activation and perform the install only (useful in CI/Docker where a transient activation failure must not fail the install). To execute an installed tool without going through an agent, run the dedicated tool-runner command. - -## Modes - -`goga install` branches on whether a tool name or `--local` path is given: - -- **Single mode** (`goga install `): install one tool, then activate. `--version` resolves through the four-form grammar; the project config is ignored. -- **Local mode** (`goga install --local ` / `-l `): pip-install a local directory (no PyPI lookup). Mutually exclusive with `name`; `--version` is rejected. Activation follows the single/bulk rules. -- **Bulk mode** (`goga install`): install every tool declared in the `tools` section of `.goga/config.yml`, in a single pip invocation in YAML insertion order, then one activation pass. -- **Empty mode** (`goga install` with no `tools` section): no-op — prints `Nothing to install` and exits 0. pip is not invoked, and neither is activation. - -## Usage - -### Single mode - -```bash -# Plain install — current user, latest version -goga install foo - -# Install a specific concrete version -goga install foo --version 1.0.1 - -# Install within a minor x-range (>=1.0.0, <1.1.0) -goga install foo --version 1.0.x - -# Install within a major x-range (>=1.0.0, <2.0.0) -goga install foo --version 1.x - -# Pin to latest explicitly (same as omitting --version) -goga install foo --version latest - -# Install with sudo (system-Python installs requiring root); activation runs -# without sudo against the preserved $HOME -goga install foo --sudo - -# Install only — skip activation (escape-hatch for CI/Docker) -goga install foo --no-connect -``` - -### Bulk mode - -Declare tools in `.goga/config.yml`: - -```yaml -tools: - viewer: latest # → no specifier (pip selects newest) - afm: 1.0.x # → ~=1.0.0 (minor x-range, >=1.0.0,<1.1.0) - ralphex: 1.x # → ~=1.0 (major x-range, >=1.0.0,<2.0.0) - go: 1.0.1 # → ==1.0.1 (concrete pin) -``` - -Then install them all in a single pip invocation: - -```bash -# Install every declared tool under the current user -goga install - -# Same, but under sudo with HOME preserved -goga install --sudo - -# Bulk install only, no activation -goga install --no-connect -``` - -Bulk mode issues **exactly one** `pip install` whose argv contains every resolved `goga-tool-` in YAML order, followed by one activation pass. This lets pip's resolver see the whole set together under `-U`, avoiding dependency drift between sequential installs. - -### Local mode - -Install a pip-installable local directory instead of resolving a package from PyPI: - -```bash -# Install a tool from a local source checkout -goga install --local ./my-tool - -# Short alias -goga install -l ./my-tool - -# Local install only, no activation -goga install --local ./my-tool --no-connect - -# Under sudo (pip only; activation never uses sudo) -goga install --local ./my-tool --sudo -``` - -Local mode issues a single `pip install -U` (never `-e`/editable) so the directory is installed the same way a named package is. pip owns the missing-path error — there is no CLI-level existence check, so an invalid path surfaces as pip's own failure with its verbatim return code. The `name` positional and `--local` are mutually exclusive, and `--version` is rejected in local mode (both exit 1 with a clear message). The project config is **not** read in local mode; activation follows the single/bulk rules. - -## Options - -| Option | Type | Default | Purpose | -|---|---|---|---| -| `name` (positional, optional) | string | None | Tool name without the `goga-tool-` prefix. When absent, bulk/empty mode runs from `.goga/config.yml`. | -| `--sudo` | flag | False | Run pip under `sudo --preserve-env=HOME` (Unix-only). Applies to pip only; activation never uses sudo. Applies to single, local, and bulk modes. | -| `--version
`, `-v ` | string | None | Version form in the four-form grammar. Used by single mode only; ignored in bulk mode. Both forms are aliases on the same option — `-v 1.0.x` is identical to `--version 1.0.x`. | -| `--local `, `-l ` | string | None | Path to a pip-installable local directory (local mode). Mutually exclusive with `name`; `--version` is rejected in local mode. | -| `--no-connect` | flag | False | Skip post-install agent activation. When set, the command performs the install only and the exit code is pip's. | - -## Post-install activation - -When pip succeeds in single or bulk mode and `--no-connect` is not set, the command activates every agent listed in `~/.goga/connect.yml`, each with its own recorded `force_overwrite`. Activation is a local operation on `$HOME` and **never** runs under `--sudo` — only pip honors `--sudo`. A missing or empty registry is a no-op that returns 0: the tool is installed on the interpreter but not yet linked to any agent. Connect an agent later with `goga connect ` and the tool will be picked up on the next install/upgrade. - -## Version form grammar - -`goga install` emits the operator. The four accepted forms resolve to pip specifiers as follows: - -| Form | Example | Resolved pip specifier | Semantics | -|---|---|---|---| -| Minor x-range | `1.0.x` | `~=1.0.0` | PEP 440 compatible release: `>=1.0.0,<1.1.0` | -| Major x-range | `1.x` | `~=1.0` | PEP 440 compatible release: `>=1.0.0,<2.0.0` | -| Concrete | `1.0.1` | `==1.0.1` | Exact pin | -| Latest marker | `latest` | *no specifier* | pip selects newest under `-U` | - -The following forms are **rejected** with exit code 1 and a clear error: - -| Rejected form | Reason | -|---|---| -| `==1.0`, `>=1.0`, `~=1.0`, `<2.0`, `!=1.0` | Operator-prefixed — write the grammar form instead; the command emits the operator | -| `foo`, `1.x.0`, `1.0.x.y` | Malformed — not in the four-form grammar | -| YAML-null `tools` value (e.g. `viewer:`) | Structural type error in the loader — write `latest` explicitly | - -## Exit codes - -| Exit code | Condition | -|---|---| -| 0 | pip succeeded and activation succeeded (or the registry is missing/empty), or empty mode no-op | -| non-zero (pip) | pip failed — its returncode propagated verbatim, with no translation; activation is not run | -| non-zero (activation) | pip succeeded but activation failed for one or more agents — the first non-zero per-agent failure is returned | -| 1 | a version form was rejected, `name` and `--local` were both given, `--version` was given with `--local`, `.goga/config.yml` could not be loaded in bulk/empty mode, or the pip/sudo executable could not start | - -With `--no-connect`, the exit code is always pip's (install-only semantics). - -## Notes - -- The current interpreter (`sys.executable`) must be the one where goga is installed. -- The caller needs write access to the site-packages directory (or pass `--sudo`) and, for activation, to `~/.goga/`. -- For bulk mode, `.goga/config.yml` must exist and contain a `tools` section (otherwise empty mode runs — a no-op, not an error). -- On Windows, `--sudo` is unavailable (sudo is Unix-only). -- Do not bypass this command and call `pip` with `sudo` directly without `--preserve-env=HOME`: post-install activation depends on reading the caller's `$HOME`. -- In CI/Docker where a transient activation failure must not fail the install, pass `--no-connect` to keep install-only exit semantics. diff --git a/docs/configuration/agents.md b/docs/configuration/agents.md index d4d281fe..b7421215 100644 --- a/docs/configuration/agents.md +++ b/docs/configuration/agents.md @@ -23,7 +23,7 @@ Edge cases: | Wrapper file missing in image (custom Dockerfile forgot `COPY`) | Path resolves but the file is absent. | Runtime error inside the container. No upfront validation by goga. | | Wrapper present but not executable (forgot `chmod +x`) | Permission denied. | Runtime error inside the container. | | `cursor` configured without `CURSOR_API_KEY` | The wrapper is env-based, not credential-file-based — there is no credential mount to fall back on. | Wrapper exits with error (`CURSOR_API_KEY is required`). See [cursor](#cursor). | -| `workflow.stages..agent: ` | Wrapper path is composed verbatim; no validation against a known agent set. | Runtime error inside the container. See [workflows](../pipelines/workflows.md#workflow-agent-choosing-the-cli-agent). | +| `workflow.stages..agent: ` | Wrapper path is composed verbatim; no validation against a known agent set. | Runtime error inside the container. See [workflows](../features/pipelines/workflows.md#workflow-agent-choosing-the-cli-agent). | ## Baseline wrappers @@ -41,7 +41,7 @@ The wrapper class describes how each wrapper produces the Claude Code stream-jso ## Environment variables per agent -Env variables are forwarded into the container through the standard env layering (`home.env` → project `.env` → CLI `-e` / `extra_env`) — see [Home configuration](./index.md#home-configuration). +Env variables are forwarded into the container through the standard env layering (`home.env` → project `.env` → CLI `-e` / `extra_env`) — see [Home configuration](home.md#env-layering). ### claude @@ -51,6 +51,7 @@ Env variables are forwarded into the container through the standard env layering | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | no | Claude default | Override for the Haiku-class model slot. `goga init` suggests this when claude is the agent. | | `ANTHROPIC_DEFAULT_SONNET_MODEL` | no | Claude default | Override for the Sonnet-class model slot. Suggested by `goga init`. | | `ANTHROPIC_DEFAULT_OPUS_MODEL` | no | Claude default | Override for the Opus-class model slot. Suggested by `goga init`. | +| `ANTHROPIC_MODEL` | no | Claude default | Override for the main model slot. Suggested by `goga init`. | | `ANTHROPIC_BASE_URL` | no | Claude default | Base URL for an Anthropic-compatible gateway or proxy. Suggested by `goga init`. | ### codex @@ -108,7 +109,7 @@ Any name works as `agent: ` as long as `/home/goga/bin/-as-claude.sh Two paths, both through a custom Dockerfile: -**Path A — via the `dockerfile:` field.** When `.goga/config.yml` declares a top-level `dockerfile` (see [Top-level](./index.md#top-level) and [Example configuration](./index.md#example-configuration)), `goga build --update` / `goga pipeline --update` build the image from that Dockerfile: +**Path A — via the `dockerfile:` field.** When `.goga/config.yml` declares a top-level `dockerfile` (see [Top-level](project.md#top-level) and [Example configuration](project.md#example-configuration)), `goga build --update` / `goga pipeline --update` build the image from that Dockerfile: ```dockerfile FROM qarium/goga-python-: # or any baseline language image @@ -130,4 +131,4 @@ If `agent: myname` is set but the wrapper is not `COPY`'d into the image or is n ## Relationship to `goga connect` -> **Two different `agent` concepts.** The runtime `agent` (this section) picks which CLI binary runs **inside the goga Docker container** during `goga build` / `goga pipeline`. [`goga connect`](../cli/connect.md) is a separate, host-side mechanism that installs goga skills and commands **into** an AI agent (claude/codex/cursor/opencode/qwen) as a target. They are orthogonal: you can run `goga connect claude codex` to get goga skills inside both of your host-installed CLIs, and still set `build.task_executor.agent: codex` — in that case the codex wrapper runs inside the container, not your host-side CLI. +> **Two different `agent` concepts.** The runtime `agent` (this section) picks which CLI binary runs **inside the goga Docker container** during `goga build` / `goga pipeline`. [`goga connect`](../features/connect/cli.md) is a separate, host-side mechanism that installs goga skills and commands **into** an AI agent (claude/codex/cursor/opencode/qwen) as a target. They are orthogonal: you can run `goga connect claude codex` to get goga skills inside both of your host-installed CLIs, and still set `build.task_executor.agent: codex` — in that case the codex wrapper runs inside the container, not your host-side CLI. diff --git a/docs/cli/config.md b/docs/configuration/cli.md similarity index 96% rename from docs/cli/config.md rename to docs/configuration/cli.md index 11cc3c6f..d2eff8cd 100644 --- a/docs/cli/config.md +++ b/docs/configuration/cli.md @@ -62,7 +62,7 @@ Values are read from `.goga/config.yml`. A minimal configuration: ```yaml language: python -image: qarium/goga-python-3.12:1.2 # top-level image, shared by build and pipeline (build.image is rejected) +image: qarium/goga-python-3.12:1.3 # top-level image, shared by build and pipeline (build.image is rejected) build: task_executor: agent: claude # optional at the loader level; goga build raises a ClickException when it is None diff --git a/docs/configuration/home.md b/docs/configuration/home.md index c4ab203d..04da77fa 100644 --- a/docs/configuration/home.md +++ b/docs/configuration/home.md @@ -30,6 +30,8 @@ malformed entry (an unterminated quote) fails to load with a clean error. | `docker.run` | list of strings | Shell fragments appended to every `docker run` invocation in both `goga build` and `goga pipeline`. Each entry is shell-tokenized (e.g. `-v /host:/container` → `-v` + volume spec) | | `docker.build` | list of strings | Shell fragments appended to image builds only — forwarded by both `goga build` and `goga pipeline` (`docker_build_if_not_exist` / `docker_update`, build branch only; ignored on image pull). Each entry is shell-tokenized like `docker.run` | +## Env layering + The env layering formula is `{**home.env, **project_env, **cli_env}` — `home.env` is the base, project config wins over it, and CLI extra env wins last. Unknown keys are ignored. diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 146966ae..03d32370 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -4,9 +4,18 @@ goga reads configuration from two files: the **project** config `.goga/config.ym | File | Scope | Required | |---|---|---| -| [Project Configuration](project.md) — `.goga/config.yml` | One project: language, image, build and pipeline executors, codemanifest, tools, usages, lint | Required for `goga build` / `goga pipeline` | +| [Project Configuration](project.md) — `.goga/config.yml` | One project: language, image, build and pipeline executors, codemanifest, tools, usages, lint, topics | Required for `goga build` / `goga pipeline` | | [Home Configuration](home.md) — `~/.goga/config.yml` | Whole machine: base env layer, extra `docker run` / `docker build` arguments | Optional — absent by default | +## Pages in this section + +| Page | Content | +|---|---| +| [Project Configuration](project.md) | `.goga/config.yml` — global fields, the `codemanifest` section, and the map of domain-owned sections | +| [Home Configuration](home.md) | `~/.goga/config.yml` — the machine-wide layer | +| [Agents](agents.md) | How `agent: ` values in both configs resolve into wrapper scripts inside the Docker container | +| [`goga config`](cli.md) | Read configuration values back from the command line | + The home config is the lower-priority layer: `home.env` is the base of the env layering formula `{**home.env, **project_env, **cli_env}`, and `docker.run` / `docker.build` fragments are appended to every container invocation regardless of the project. See [Home Configuration](home.md#env-layering) for the layering details. -See also [Agents](agents.md) for how `agent: ` values in both configs resolve into wrapper scripts inside the Docker container. +Every domain-owned section of the project config (`build`, `pipeline`, `tools`, `usages`, `lint`, `topics`) is documented in full in its domain's **Configuration** page — see [Project Configuration — Domain sections](project.md#domain-sections). diff --git a/docs/configuration/project.md b/docs/configuration/project.md index 3df6c369..0bb6d6a2 100644 --- a/docs/configuration/project.md +++ b/docs/configuration/project.md @@ -2,6 +2,8 @@ goga reads project configuration from `.goga/config.yml` in the project root. This file is created by `goga init` and can be edited manually. +The page covers the **global** fields and the sections that belong to no single domain. Every domain-owned section (`build`, `pipeline`, `tools`, `usages`, `lint`, `topics`) is documented in full in its domain's **Configuration** page — see [Domain sections](#domain-sections). + ## File location ``` @@ -10,13 +12,13 @@ goga reads project configuration from `.goga/config.yml` in the project root. Th The config loader looks for this file relative to the current working directory. -For the machine-wide `~/.goga/config.yml`, see [Home Configuration](home.md). +For the machine-wide `~/.goga/config.yml`, see [Home Configuration](home.md). To read values back from the command line, see [`goga config`](cli.md). ## Example configuration ```yaml language: python -image: qarium/goga-python-3.14:1.2 +image: qarium/goga-python-3.14:1.3 # dockerfile: .goga/Dockerfile # optional — when set, `--update` builds from this Dockerfile instead of pulling build: @@ -36,6 +38,8 @@ build: # roles: [quality, testing] # reviewer composition; absent/[] → full default set # env: # review-pass env layer (requires agent when non-empty) # ANTHROPIC_MODEL: reviewer + # base_ref: origin/1.2.x # review diff base — branch name or commit hash + # patience: 3 # stop the external review after N unchanged rounds # proxy: http://corp:3123 # optional HTTP/HTTPS proxy URL for the build container # hosts: # optional docker run --add-host entries # foo.local: 127.0.0.1 @@ -75,6 +79,11 @@ codemanifest: # ignore: # - .venv/ # - build/dist + +# topics: optional — topic creation base and publication template (`goga topics create`) +# topics: +# base_ref: origin/main # base of the created topic branches +# publish_commit: "goga: create topic {slug}" # commit message template ({slug} optional) ``` ## Fields reference @@ -84,108 +93,50 @@ codemanifest: | Field | Type | Required | Description | |-------|------|----------|-------------| | `language` | `string` | Yes | Project language. One of: `python`, `golang`, `kotlin`, `swift`, `javascript` | -| `image` | `string` | No | Docker image used by `goga build` and `goga pipeline` (e.g. `qarium/goga-python-3.14:1.2`). Consumers raise an error when it is unset. | +| `image` | `string` | No | Docker image used by `goga build` and `goga pipeline` (e.g. `qarium/goga-python-3.14:1.3`). Consumers raise an error when it is unset. The deprecated `build.image` field is rejected — set this top-level field instead | | `dockerfile` | `string` | No | Path to a project Dockerfile. When set, `goga build --update` and `goga pipeline --update` build the image locally from this Dockerfile (fatal on build failure). When unset (default), `--update` pulls `image` from the registry instead (non-fatal warning on pull failure) | -| `build` | mapping | No | Build pipeline settings. Optional at the loader level; `goga build` raises a `ClickException` when the section is absent | -| `pipeline` | mapping | No | Pipeline (afm) execution settings. Optional at the loader level; `goga pipeline` raises a `ClickException` when the section is absent | | `commands` | mapping | No | Reserved for future prompt customization. Defaults to `{}` | -| `codemanifest` | mapping | No | Global codemanifest configuration | -| `tools` | mapping | No | goga-tool version declarations consumed by `goga install` in bulk mode. Keys are tool names (without the `goga-tool-` prefix); values are version-form strings. Values are stored verbatim — the four-form grammar (`1.0.x`, `1.x`, `1.0.1`, `latest`) is validated by `goga install`, not the loader. Defaults to `None` (absent); an empty mapping is `{}`. YAML-null values (`viewer:`) are rejected | -| `usages` | mapping | No | Git dependencies whose cell-level `.usages/` files are synced into `.goga/usages///` by [`goga usages sync`](../cli/usages.md) and checked for drift against the remote by [`goga usages status`](../cli/usages.md). Two-level mapping: `` → `` → `{ git, ref, root }`. Defaults to `None` (absent), which makes `goga usages sync` a no-op (exit 0); an empty mapping is `{}`. `` and `` keys are validated as filesystem path segments — empty, `.` / `..`, or any name containing `/` or `\` raise `ValueError` | -| `lint` | mapping | No | Optional linter section consumed by [`goga lint`](../cli/lint.md). Currently holds `ignore`, a list of directory relative paths to prune from lint traversal. Defaults to `None` (absent); an empty mapping is equivalent to no ignore list. Structural type errors (non-mapping `lint`, non-list `lint.ignore`, or a non-string element) raise `ValueError` | - -### build - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `task_executor` | mapping | Yes | AI agent configuration | -| `worktree` | `bool` | No | Use isolated git worktree for builds | -| `skip_finalize` | `bool` | No | Skip the ralph-loop finalization step | -| `session_timeout` | `string` | No | Session timeout in Go duration format (e.g. `30m`, `1h`) | -| `idle_timeout` | `string` | No | Idle timeout in Go duration format | -| `wait` | `string` | No | Wait time on rate limit in Go duration format | -| `max_iterations` | `int` | No | Maximum task iterations | -| `review_patience` | `int` | No | Stop review after N unchanged rounds | -| `prompts_dir` | `string` | No | Path to custom ralph-loop prompts | -| `agents_dir` | `string` | No | Path to custom ralph-loop agents | -| `codex_review` | `bool` | No | Enable external codex review | -| `proxy` | `string` | No | HTTP/HTTPS proxy URL for the build container. When set, `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` are written to the container env-file. Overridden by the `--proxy` CLI option | -| `hosts` | mapping | No | Host→IP mapping for `docker run --add-host`. Defaults to `{}`. Augmented by the repeatable `--add-host` CLI option (CLI wins on key conflict) | -| `review_executor` | mapping | No | Review-phase configuration. See [build.review_executor](#buildreview_executor) | - -> The deprecated `build.image` field is rejected with a `ValueError`. Set the top-level `image` field instead. - -### build.task_executor - -| Field | Type | Required | Description | -|---------|----------|-----------|-------------------------------------------------------------------------------------------------------------------------| -| `agent` | `string` | No | AI executor that runs the build inside the container. Optional at the loader level — absent/YAML-null/empty/whitespace resolves to `None`; `goga build` raises a `ClickException` when it is `None` (the build needs an agent to resolve the in-container wrapper). Resolved to `/home/goga/bin/-as-claude.sh` — no whitelist; any name whose wrapper file exists in the image works. Baseline wrappers: `claude`, `codex`, `cursor`, `opencode`, `qwen`. See [Agents](./agents.md) for the resolution mechanic, per-agent env variables, and how to add a custom agent. | -| `env` | mapping | No | Environment variables passed to the agent. Keys and values must be strings. Defaults to `{}` | - -### build.review_executor - -Optional section controlling the review phase of `goga build`. When absent, the full cycle (tasks + review) runs in a single pass with the task executor's wrapper. - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `skip` | `bool` | No | Skip the review phase entirely — the run executes tasks only (ralph-loop `--tasks-only`). Absent/YAML-null means "not set" (the CLI flag decides); must be a real bool — a YAML `1` is rejected | -| `agent` | `string` | No | Review executor agent name (same resolution mechanic as `build.task_executor.agent`; its wrapper must exist in the image). When it differs from `task_executor.agent`, **or when a non-empty `env` is declared alongside it**, the build runs two passes: tasks with the task wrapper, then the review pass with the review wrapper. Combining either two-pass form with an active worktree (`--worktree` or `build.worktree: true`) is rejected with exit 1 on the host | -| `roles` | list of `string` | No | Reviewer composition for the review prompts: keeps only the `{{agent:X}}` lines of the selected roles and adapts the counters of the accompanying text. Whitelist: `quality`, `implementation`, `testing`, `simplification`, `documentation`. Absent or `[]` means the full default set (prompts stay byte-identical to the vendored defaults) | -| `env` | mapping of `string` | No | Review-pass environment layer (`{str: str}`). Keys overlay same-named container variables for the review-pass subprocess only — the tasks pass and the container env-file are unaffected, and the values never reach logs or dry-run output. Absent/YAML-null/`{}` all resolve to `{}` (unlike `build.task_executor.env`, where YAML-null is an error). A non-empty `env` induces a two-pass run like a differing agent does, and requires `agent` — a non-empty `env` without `agent` fails in-container validation when the review phase runs; a skipped run ignores the layer entirely | - -Precedence: the `--skip-review`/`--no-skip-review` CLI pair overrides `skip`; an explicit `--no-skip-review` forces the full cycle even when the config sets `skip: true`. Role names, the env-requires-agent rule, and the review wrapper are validated in-container before any pass runs — but only when the review phase will actually run (a skipped run never validates them). - -### pipeline - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `agent` | `string` | No | AI agent that runs the pipeline stages inside the container. Optional at the loader level — absent/YAML-null/empty/whitespace resolves to `None`. When `None`, the agent may be supplied by a per-stage workflow override (see [Workflows](../pipelines/workflows.md)) or afm's own default, so `goga pipeline` does not require it. Same resolution mechanic and baseline set as `build.task_executor.agent` — see [Agents](./agents.md). | -| `env` | mapping | No | Environment variables passed into the pipeline container. Keys and values must be strings. Defaults to `{}` | -| `proxy` | `string` | No | HTTP/HTTPS proxy URL for the pipeline container. When set, `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` are written to the container env-file. Overridden by the `--proxy` CLI option | -| `hosts` | mapping | No | Host→IP mapping for `docker run --add-host`. Defaults to `{}`. Augmented by the repeatable `--add-host` CLI option (CLI wins on key conflict) | +| `codemanifest` | mapping | No | Global codemanifest configuration — see [codemanifest](#codemanifest) | +| `build` | mapping | No | Build pipeline settings — see [Build — Configuration](../features/build/configuration.md) | +| `pipeline` | mapping | No | Pipeline (afm) execution settings — see [Pipelines — Configuration](../features/pipelines/configuration.md) | +| `tools` | mapping | No | goga-tool version declarations for bulk install — see [Install — Configuration](../features/install/configuration.md) | +| `usages` | mapping | No | Git dependencies of cell-level usages — see [Usages — Configuration](../features/usages/configuration.md) | +| `lint` | mapping | No | Linter ignore list — see [Lint — Configuration](../features/lint/configuration.md) | +| `topics` | mapping | No | Topic creation base and publication template — see [Topics — Configuration](../features/topics/configuration.md) | + +### Domain sections + +Each domain-owned section is documented in full — every field, typing rule, and CLI precedence — in its domain's **Configuration** page: + +| Section | Domain | Consumed by | +|---|---|---| +| `build` (incl. `task_executor`, `review_executor`) | [Build](../features/build/configuration.md) | `goga build` | +| `pipeline` | [Pipelines](../features/pipelines/configuration.md) | `goga pipeline` | +| `tools` | [Install](../features/install/configuration.md) | `goga install` (bulk mode) | +| `usages` | [Usages](../features/usages/configuration.md) | `goga usages sync` / `goga usages status` | +| `lint` | [Lint](../features/lint/configuration.md) | `goga lint` | +| `topics` | [Topics](../features/topics/configuration.md) | `goga topics create` | ### codemanifest +The `codemanifest` section is global — it belongs to no single domain. It feeds every CODEMANIFEST of the project (see [Cell](../cell/index.md)). + | Field | Type | Required | Description | |-------|------|----------|-------------| | `usages` | mapping | No | Named practices available in CODEMANIFEST files. Format: `{name: path/to/file.md}`. Defaults to `{}` | | `annotations` | `string` | No | Free-text instructions for AI agents. Defaults to `None` | -### usages - -Git dependencies whose cell-level `.usages/` files are synced into `.goga/usages///` by [`goga usages sync`](../cli/usages.md) and checked for drift against the remote by [`goga usages status`](../cli/usages.md). - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `usages.` | mapping | Yes when `usages` present | Group bucket. The key becomes a top-level subdirectory of `.goga/usages/`. Validated as a path segment (no empty / `.` / `..` / `/` / `\`). | -| `usages..` | mapping | Yes when `` present | Dependency entry. The key becomes a subdirectory under the group. Same path-segment validation. | -| `usages...git` | `string` | Yes | Git URL of the source repository. Must be non-empty. | -| `usages...ref` | `string` | No | Git ref — branch, tag, or commit. `None` (omitted) clones the default branch. | -| `usages...root` | `string` | No | Subpath inside the clone to discover `.usages` folders from. Absent (or an empty string) → clone root. Must be relative; no `..` or absolute paths (leading `/` or UNC `//host/share`). | - -When `usages` is absent, `config.usages` is `None` and `goga usages sync` exits `0` without invoking git. A present-but-non-mapping value raises `ValueError`. - -### lint - -Optional section consumed by [`goga lint`](../cli/lint.md) to prune directories from validation. - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `lint.ignore` | list of strings | No | Directory relative paths to skip during lint traversal, stored verbatim. A directory matches when its exact normalized relative path equals an entry; glob patterns are not interpreted and a trailing separator is insignificant. Defaults to `[]` when `lint` is present but `ignore` is absent | - -When `lint` is absent, `config.lint` is `None` and `goga lint` lints every directory. A present-but-non-mapping `lint`, a non-list `lint.ignore`, or a non-string element raises `ValueError`. The `lint` command derives `ignore` **tolerantly** — any loader error falls back to no filtering rather than failing the lint run. - ## Pre-built Docker images goga provides prebuilt language images for build execution: | Language | Images | |----------|--------| -| Python | `qarium/goga-python-3.10:1.2` through `qarium/goga-python-3.14:1.2` | -| Go | `qarium/goga-golang-1.23:1.2` through `qarium/goga-golang-1.26:1.2` | -| JavaScript | `qarium/goga-node-22:1.2`, `qarium/goga-node-24:1.2` | -| Kotlin | `qarium/goga-kotlin-2.0:1.2` through `qarium/goga-kotlin-2.3:1.2` | -| Swift | `qarium/goga-swift-6.0:1.2` through `qarium/goga-swift-6.2:1.2` | +| Python | `qarium/goga-python-3.10:1.3` through `qarium/goga-python-3.14:1.3` | +| Go | `qarium/goga-golang-1.23:1.3` through `qarium/goga-golang-1.26:1.3` | +| JavaScript | `qarium/goga-node-22:1.3`, `qarium/goga-node-24:1.3` | +| Kotlin | `qarium/goga-kotlin-2.0:1.3` through `qarium/goga-kotlin-2.3:1.3` | +| Swift | `qarium/goga-swift-6.0:1.3` through `qarium/goga-swift-6.2:1.3` | ## Validation errors @@ -195,7 +146,7 @@ The config loader raises specific exceptions for invalid configuration: |-------|-------| | `FileNotFoundError` | `.goga/config.yml` does not exist or is empty | | `KeyError` | Missing required field (`language`, or `build.task_executor` when `build` is present) | -| `ValueError` | Invalid field value (wrong type, empty string, non-mapping where mapping expected), or the deprecated `build.image` field is present. `build.review_executor` adds: non-mapping section (`build.review_executor must be a mapping`), non-bool `skip` (a YAML `1` is rejected), non-string `agent`, `roles` that is not a list of strings, a non-mapping `env` (`build.review_executor.env must be a mapping in .goga/config.yml`), or `env` with non-string keys/values (`build.review_executor.env must have string keys and values`) | +| `ValueError` | Invalid field value (wrong type, empty string, non-mapping where mapping expected), or the deprecated `build.image` field is present. `build.review_executor` adds: non-mapping section (`build.review_executor must be a mapping`), non-bool `skip` (a YAML `1` is rejected), non-string `agent`, `roles` that is not a list of strings, a non-mapping `env` (`build.review_executor.env must be a mapping in .goga/config.yml`), `env` with non-string keys/values (`build.review_executor.env must have string keys and values`), a non-string `base_ref` (`build.review_executor.base_ref must be a string in .goga/config.yml`), or a non-int `patience`, including a YAML boolean (`build.review_executor.patience must be an int in .goga/config.yml`). `topics` adds: a non-mapping section (`'topics' must be a mapping in .goga/config.yml`) or a non-string field (`topics.base_ref must be a string in .goga/config.yml`, `topics.publish_commit must be a string in .goga/config.yml`) | ## Implementation details diff --git a/docs/features/build/api.md b/docs/features/build/api.md new file mode 100644 index 00000000..783562fe --- /dev/null +++ b/docs/features/build/api.md @@ -0,0 +1,48 @@ +# Build — API + +The facade of the domain package **`goga.build`** — the host-side orchestration of a plan execution through the ralph-loop in a Docker container. + +The signatures below are the CODEMANIFEST contract of the cell. + +## Entry points + +```python +build(plan: str, config: ProjectConfig, cli_options: dict) -> int +main() -> int +``` + +`build` is the full orchestration — precondition checks (Docker, config, uncommitted manifests), agent wrapper resolution, ralphex defaults sync, optional image refresh, and the container launch; the exit code is returned. `main` is the console entry point. The `cli_options` dict carries the CLI-surface values (timeouts, `--update`, review flags, …) resolved by the command layer. + +## Review options + +```python +resolve_review_options(config: BuildConfig, cli_options: dict) -> ReviewOptions +validate_review_config(config: BuildConfig, review: ReviewOptions) -> None +ReviewOptions(skip: bool, review_agent: str | None, roles: list[str] | None, + two_pass: bool, review_env: dict[str, str], + base_ref: str | None, patience: int | None) +``` + +`resolve_review_options` composes the review-scoped settings with the precedence CLI > `build.review_executor.*` > omit. `validate_review_config` enforces the host-side guards — among them the rejection of a two-pass review form combined with an active worktree. + +## Run plumbing + +```python +sync_ralphex_defaults(config: BuildConfig, review: ReviewOptions) -> None +write_ralphex_config(config: BuildConfig, wrapper_path: str) -> None +run_build_pass(plan: str, config: BuildConfig, options: dict[str, str | int | bool], + wrapper_path: str, dry_run: bool, + env: dict[str, str] | None = None) -> int +move_completed_plan(plan: str, outcome: bool, dry_run: bool) -> None +``` + +`sync_ralphex_defaults` rewrites `.ralphex/prompts/` and `.ralphex/agents/` from the configured or vendored defaults (filtering review prompts to the selected `roles`); `write_ralphex_config` writes the ralph-loop config with the resolved wrapper. `run_build_pass` launches one container pass (tasks or review) — `dry_run=True` prints the assembled command. `move_completed_plan` moves the plan into the topic's `completed/` directory after the run. + +## Example + +```python +from goga.build import build +from goga.config import load_project_config + +exit_code = build("plan.md", load_project_config(), {"update": False, "dry_run": False}) +``` diff --git a/docs/cli/build.md b/docs/features/build/cli.md similarity index 87% rename from docs/cli/build.md rename to docs/features/build/cli.md index 380c88cf..50b61694 100644 --- a/docs/cli/build.md +++ b/docs/features/build/cli.md @@ -17,7 +17,7 @@ The build pipeline performs these steps: 1. **Docker check** -- Verifies Docker is installed and accessible. 2. **Config loading** -- Reads `.goga/config.yml` for build settings. 3. **Uncommitted manifest check** -- Scans `git status` for uncommitted `CODEMANIFEST` files (can be skipped). -4. **Agent preconditions** -- Sets up agent-specific files (e.g., `.claude/settings.json`, `.ralphex/claude-wrapper.sh` for Claude). A review executor whose `agent` differs from the task executor, or that declares a non-empty `env`, combined with an active worktree (`--worktree` or `build.worktree: true`) is rejected here with exit 1, before any container launch — the ralph-loop review mode cannot follow a worktree branch. The guard is config-level and skip-independent: `--skip-review` does not bypass it. +4. **Agent preconditions** -- Resolves the agent wrapper path into `.ralphex/config` (e.g., `claude_command = /home/goga/bin/claude-as-claude.sh`; the wrappers ship inside the image). A review executor with a set `agent` that differs from the task executor, or that declares a non-empty `env`, combined with an active worktree (`--worktree` or `build.worktree: true`) is rejected host-side with exit 1, before any container launch — the ralph-loop review mode cannot follow a worktree branch. The guard is config-level and skip-independent: `--skip-review` does not bypass it. 5. **Defaults copy** -- Fully rewrites `.ralphex/prompts/` and `.ralphex/agents/` from the configured `build.prompts_dir`/`build.agents_dir`, or from the vendored ralph-loop defaults shipped with goga (`goga/assets/ralphex/`). When `build.review_executor.roles` is set, the review prompts are filtered to the selected roles. 6. **Image refresh (optional)** -- When `--update`/`-u` is set, the image is refreshed: if a top-level `dockerfile` is declared in `.goga/config.yml`, `docker build` runs against it (build failure is fatal — exit 1); otherwise `docker pull` runs (a pull failure is logged as a warning and the build proceeds with the locally available image). By default no refresh happens and the local image is used as-is. 7. **Docker execution** -- Launches the ralph-loop command inside the configured Docker image, after a pre-launch host–image version check (see [Pre-launch version check](#pre-launch-version-check)). Credential files for claude, codex, and opencode are detected on the host and bind-mounted read-only into the container automatically (no flag). @@ -42,14 +42,17 @@ The build pipeline performs these steps: | `--wait` | string | config | Wait time before starting | | `--max-iterations` | int | config | Maximum number of build iterations | | `--review-patience` | int | config | Review patience count | -| `-e`, `--env` | string | -- | Additional environment variable (`KEY=VALUE`, repeatable) | +| `--base-ref` | string | config | Review diff base (branch name or commit hash); overrides `build.review_executor.base_ref` | +| `-e`, `--env` | string (repeatable) | -- | Additional environment variable (`KEY=VALUE`, repeatable) | | `--proxy` | string | config | HTTP/HTTPS proxy URL; overrides `build.proxy`. Adds `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` to the container env-file | | `--add-host` | string (repeatable) | -- | Add a `docker run --add-host HOST:IP` entry; merges on top of `build.hosts` (CLI wins on key conflict) | -| `--update`, `-u` | flag | off | Refresh the image before launch (build if a project Dockerfile is declared, else pull). Default skips the refresh | +| `-u`, `--update` | flag | off | Refresh the image before launch (build if a project Dockerfile is declared, else pull). Default skips the refresh | | `-c`, `--clean` | flag | off | Wipe the persistent ralph-loop runtime host directory before launch (default preserves state across runs) | Timeout and iteration options fall back to values in `.goga/config.yml` when not provided on the command line. +`--review-patience` and `--base-ref` are review-scoped: they resolve with precedence CLI > `build.review_executor.*` in `.goga/config.yml` > omit, and they apply to review-carrying passes only — the single full-cycle pass, or the review pass of a two-pass run; a tasks-only run carries neither. The legacy `build.review_patience` key is not parsed (the setting moved to `build.review_executor.patience`). + ### Proxy and hosts `--proxy URL` (and `build.proxy` in `.goga/config.yml`) route the container's traffic through a corporate proxy. When a proxy is resolved, three variables are written to the container env-file: @@ -130,6 +133,12 @@ Skip the review phase (run tasks only): goga build plan.md --skip-review ``` +Review against a specific branch or commit instead of the detected default: + +```bash +goga build plan.md --base-ref origin/1.2.x +``` + Pull the latest image, then build (default skips the pull): ```bash @@ -154,7 +163,7 @@ Build settings are loaded from `.goga/config.yml`. Example configuration: ```yaml language: python -image: qarium/goga-python-3.12:1.2 +image: qarium/goga-python-3.12:1.3 # dockerfile: .goga/Dockerfile # optional — when set, `--update` builds the image from this Dockerfile instead of pulling pipeline: agent: claude diff --git a/docs/features/build/configuration.md b/docs/features/build/configuration.md new file mode 100644 index 00000000..1966ef5b --- /dev/null +++ b/docs/features/build/configuration.md @@ -0,0 +1,54 @@ +# Build — Configuration + +The build domain reads one section of `.goga/config.yml` — `build`. The section is optional at the loader level; `goga build` raises a `ClickException` when it is absent. + +```yaml +image: qarium/goga-python-3.12:1.3 # top-level image, shared with pipelines (build.image is rejected) +build: + task_executor: + agent: claude # the agent that runs the build inside the container + env: {} + review_executor: + agent: codex # optional: a separate review-pass agent + roles: [quality, testing] + base_ref: origin/1.3.x # review diff base + patience: 3 +``` + +### `build` + +| Field | Type | Required | Description | +|---|---|---|---| +| `task_executor` | mapping | Yes | AI agent configuration — see [build.task_executor](#buildtask_executor) | +| `worktree` | `bool` | No | Use an isolated git worktree for builds | +| `skip_finalize` | `bool` | No | Skip the ralph-loop finalization step | +| `session_timeout` | `string` | No | Session timeout in Go duration format (e.g. `30m`, `1h`) | +| `idle_timeout` | `string` | No | Idle timeout in Go duration format | +| `wait` | `string` | No | Wait time on rate limit in Go duration format | +| `max_iterations` | `int` | No | Maximum task iterations | +| `prompts_dir` | `string` | No | Path to custom ralph-loop prompts | +| `agents_dir` | `string` | No | Path to custom ralph-loop agents | +| `codex_review` | `bool` | No | Enable external codex review | +| `proxy` | `string` | No | HTTP/HTTPS proxy URL for the build container. When set, `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` are written to the container env-file. Overridden by the `--proxy` CLI option | +| `hosts` | mapping | No | Host→IP mapping for `docker run --add-host`. Defaults to `{}`. Augmented by the repeatable `--add-host` CLI option (CLI wins on key conflict) | +| `review_executor` | mapping | No | Review-phase configuration — see [build.review_executor](#buildreview_executor) | + +### `build.task_executor` + +| Field | Type | Required | Description | +|---|---|---|---| +| `agent` | `string` | No | AI executor that runs the build inside the container. Optional at the loader level — absent/YAML-null/empty/whitespace resolves to `None`; `goga build` raises a `ClickException` when it is `None`. Resolved to `/home/goga/bin/-as-claude.sh` — no whitelist; any name whose wrapper file exists in the image works. Baseline wrappers: `claude`, `codex`, `cursor`, `opencode`, `qwen`. See [Agents](../../configuration/agents.md) | +| `env` | mapping | No | Environment variables passed to the agent. Keys and values must be strings. Defaults to `{}` | + +### `build.review_executor` + +| Field | Type | Required | Description | +|---|---|---|---| +| `skip` | `bool` | No | Skip the review phase entirely — the run executes tasks only (ralph-loop `--tasks-only`). Absent/YAML-null means "not set" (the CLI flag decides); must be a real bool — a YAML `1` is rejected | +| `agent` | `string` | No | Review executor agent name (same resolution mechanic as `build.task_executor.agent`; its wrapper must exist in the image). When it differs from `task_executor.agent`, **or when a non-empty `env` is declared alongside it**, the build runs two passes: tasks with the task wrapper, then the review pass with the review wrapper. Combining either two-pass form with an active worktree (`--worktree` or `build.worktree: true`) is rejected with exit 1 on the host | +| `roles` | list of `string` | No | Reviewer composition for the review prompts: keeps only the `{{agent:X}}` lines of the selected roles and adapts the counters of the accompanying text. Whitelist: `quality`, `implementation`, `testing`, `simplification`, `documentation`. Absent or `[]` means the full default set (prompts stay byte-identical to the vendored defaults) | +| `env` | mapping of `string` | No | Review-pass environment layer (`{str: str}`). Keys overlay same-named container variables for the review-pass subprocess only — the tasks pass and the container env-file are unaffected, and the values never reach logs or dry-run output. Absent/YAML-null/`{}` all resolve to `{}`. A non-empty `env` induces a two-pass run like a differing agent does, and requires `agent`; a skipped run ignores the layer entirely | +| `base_ref` | `string` | No | Review diff base — a branch name or commit hash, stored verbatim (no resolvability or format check; ralphex owns the diagnostics). Overrides ralphex's default-branch detection on review-carrying passes. Overridden by the `--base-ref` CLI option | +| `patience` | `int` | No | Stop the external review after N consecutive unchanged rounds. Absent/YAML-null resolves to `None`; a YAML boolean is rejected. Overridden by the `--review-patience` CLI option | + +The image itself is configured at the top level (`image`, `dockerfile`) — shared with [Pipelines](../pipelines/configuration.md). The general file location, loading rules, and the shared example live in [Project Configuration](../../configuration/project.md); the validation errors of the section are listed there (see [validation errors](../../configuration/project.md#validation-errors)). diff --git a/docs/features/build/hooks.md b/docs/features/build/hooks.md new file mode 100644 index 00000000..6df6db9c --- /dev/null +++ b/docs/features/build/hooks.md @@ -0,0 +1,5 @@ +# Build — Hooks + +The build domain exposes **no hook actions** for tool packages today. + +The build's extension surface is configuration-shaped instead: custom ralph-loop prompts (`build.prompts_dir`), custom agent definitions (`build.agents_dir`), and any CLI agent whose wrapper exists in the image (see [Configuration](configuration.md)). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/build/index.md b/docs/features/build/index.md new file mode 100644 index 00000000..bccaa13f --- /dev/null +++ b/docs/features/build/index.md @@ -0,0 +1,19 @@ +# Build + +Execute a build plan through a **ralph-loop** inside a Docker container. + +The build domain is the headless execution surface: a plan file (the output of the [planning cycle](../../workflow/index.md)) is walked task-by-task by an AI agent inside the isolated goga container, with an optional external review pass. Which tasks it solves: + +- **Run plans unattended** — `goga build plan.md` prepares the environment, validates preconditions (Docker, config, agent wrappers), and delegates to the ralph-loop running in-container. +- **Keep state persistent** — the ralph-loop state survives across runs of the same plan on the same branch; `--clean` wipes it for a fresh run. +- **Separate the reviewer from the executor** — `build.review_executor` configures a second agent (and an env layer) for the review pass: the build runs tasks with one wrapper, then the review with another. +- **Scope the review diff** — `base_ref` overrides the review's default-branch detection; `patience` stops the external review after N unchanged rounds. + +The interactive, stage-by-stage counterpart of this domain is [Pipelines](../pipelines/index.md); the SDD cycle that produces the plans is covered in [Workflow](../../workflow/index.md). + +## In this directory + +- [CLI](cli.md) — the full `goga build` command reference +- [Configuration](configuration.md) — the `build:` section of `.goga/config.yml` +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.build` package facade diff --git a/docs/features/connect/api.md b/docs/features/connect/api.md new file mode 100644 index 00000000..5720298f --- /dev/null +++ b/docs/features/connect/api.md @@ -0,0 +1,23 @@ +# Connect — API + +The facade of the domain package **`goga.connect`** — the installation of goga skills and commands into AI agents. + +The signatures below are the CODEMANIFEST contract of the cell. + +```python +connect(agents: list[str], force_overwrite: bool = False) -> int +install_pipelines(pipelines_dir: Path, force_overwrite: bool = False) -> int +resync_registered_agents(goga_home: Path) -> int +``` + +- `connect` — the full connection run for the named agents: install the goga skill bundle centrally into `~/.goga/skills/`, symlink it into each agent's skills directory, auto-discover the installed `goga_tool_*` packages and surface their skills and pipelines, and record the agents in `~/.goga/connect.yml`. `force_overwrite` replaces existing skills that goga does not own. Returns the exit code. +- `install_pipelines` — copy a source directory's flat `*.yml` pipeline-files into `~/.goga/pipelines/` namespaced as `:.yml` (a residual conflict on the destination is resolved with the `force_overwrite` semantics). +- `resync_registered_agents` — re-run the connection for every agent recorded in the home `connect.yml` — the re-sync invoked by `goga install`, `goga uninstall`, and `goga upgrade` after they change the installed package set. + +## Example + +```python +from goga.connect import connect + +exit_code = connect(["claude", "opencode"], force_overwrite=False) +``` diff --git a/docs/cli/connect.md b/docs/features/connect/cli.md similarity index 95% rename from docs/cli/connect.md rename to docs/features/connect/cli.md index 850a89f9..ba3eb744 100644 --- a/docs/cli/connect.md +++ b/docs/features/connect/cli.md @@ -10,7 +10,7 @@ goga connect AGENTS... [--force-overwrite] ## Description -`goga connect` installs goga's commands, skills, and DSL specification for one or more AI coding agents. Assets are installed **centrally** into `~/.goga/`, then each connected agent receives **symlinks** into that central store. A registry at `~/.goga/connect.yml` records the connected agents and their `force_overwrite` setting, so [`goga install`](install.md), [`goga upgrade`](upgrade.md), and [`goga uninstall`](uninstall.md) can re-sync them after a package change. +`goga connect` installs goga's commands, skills, and DSL specification for one or more AI coding agents. Assets are installed **centrally** into `~/.goga/`, then each connected agent receives **symlinks** into that central store. A registry at `~/.goga/connect.yml` records the connected agents and their `force_overwrite` setting, so [`goga install`](../install/cli.md), [`goga upgrade`](../upgrade/cli.md), and [`goga uninstall`](../install/uninstall.md) can re-sync them after a package change. ## Arguments diff --git a/docs/features/connect/configuration.md b/docs/features/connect/configuration.md new file mode 100644 index 00000000..fca0d302 --- /dev/null +++ b/docs/features/connect/configuration.md @@ -0,0 +1,5 @@ +# Connect — Configuration + +The connect domain reads **no section of the project `.goga/config.yml`**. + +Its state lives at the home level instead: the connected agents are recorded in `~/.goga/connect.yml` with each agent's persisted `force_overwrite` flag — see [Configuration — Home](../../configuration/home.md). The general configuration model of the product is covered in [Configuration](../../configuration/index.md). diff --git a/docs/features/connect/hooks.md b/docs/features/connect/hooks.md new file mode 100644 index 00000000..90c8217b --- /dev/null +++ b/docs/features/connect/hooks.md @@ -0,0 +1,5 @@ +# Connect — Hooks + +The connect domain exposes **no hook actions** for tool packages today. + +A tool package reaches the agent layer through its artifacts, not hooks: its skills are installed into `~/.goga/skills/` and its pipeline-files into `~/.goga/pipelines/` by the connect run itself (see [Overview](index.md)). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/connect/index.md b/docs/features/connect/index.md new file mode 100644 index 00000000..393cebed --- /dev/null +++ b/docs/features/connect/index.md @@ -0,0 +1,18 @@ +# Connect + +Install goga skills and commands into AI coding agents. + +The connect domain wires goga into the agent layer. Which tasks it solves: + +- **One-time setup** — `goga connect ` installs the goga skill bundle centrally into `~/.goga/skills/` and symlinks it into each named agent's skills directory, so `/goga:` slash commands and the `goga-*` skills appear in the agent session. +- **Tool surfacing** — the same run auto-discovers every installed `goga_tool_*` package: their skills land in the shared catalog and their pipeline-files install into `~/.goga/pipelines/` namespaced as `:.yml`. +- **Re-sync** — every command that changes the installed package set (`goga install`, `goga uninstall`, `goga upgrade`) re-syncs the registered agents through this domain, keeping `~/.goga/` and each agent's symlink tree in step. + +The connected agents are recorded in `~/.goga/connect.yml` (the home-level state — see [Configuration — Home](../../configuration/home.md)); the agent wrapper mechanics used by build and pipelines are covered in [Configuration — Agents](../../configuration/agents.md). + +## In this directory + +- [CLI](cli.md) — the full `goga connect` command reference +- [Configuration](configuration.md) — the domain reads no project configuration section +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.connect` package facade diff --git a/docs/features/contract/api.md b/docs/features/contract/api.md new file mode 100644 index 00000000..50f8b4f4 --- /dev/null +++ b/docs/features/contract/api.md @@ -0,0 +1,38 @@ +# Contract — API + +The facade of the domain package **`goga.contract`** — the comparison of CODEMANIFEST declarations with the implementation. The per-language extractors live in the nested cells (`goga.contract.python`, `.golang`, `.kotlin`, `.swift`, `.javascript`); the shared contract model in `goga.contract.data`. + +The signatures below are the CODEMANIFEST contract of the cell. + +```python +contract(lang: str, cell_path: str) -> list[EntityContract | RoutineContract] +``` + +Parse the cell's `CODEMANIFEST`, extract the implementation surface of `cell_path`'s source files with the `lang` extractor, and return the contract view — one `EntityContract` or `RoutineContract` per declared type, carrying the match between the declaration and the implementation. + +```python +BaseContract() +EntityContract(...) # a declared entity: methods and properties matched against the code +RoutineContract(...) # a declared routine: the callable signature matched against the code +MethodContract(...) +PropertyContract(...) +``` + +The contract result types. The per-language entry points are re-exported on the facade: + +```python +python_contract(...) # goga.contract.python +golang_contract(...) # goga.contract.golang +kotlin_contract(...) # goga.contract.kotlin +swift_contract(...) # goga.contract.swift +javascript_contract(...) # goga.contract.javascript +``` + +## Example + +```python +from goga.contract import contract + +for entry in contract("python", "goga/topics"): + print(entry) +``` diff --git a/docs/cli/contract.md b/docs/features/contract/cli.md similarity index 94% rename from docs/cli/contract.md rename to docs/features/contract/cli.md index 7def3dd3..e3570b58 100644 --- a/docs/cli/contract.md +++ b/docs/features/contract/cli.md @@ -18,7 +18,7 @@ This command is useful for detecting drift between what is declared in CODEMANIF | Argument | Required | Description | |---|---|---| -| `CELLS` | yes | One or more cell paths to compare (variadic). | +| `CELLS` | no | Zero or more cell paths to compare (variadic). When omitted, no cell is compared and the output is an empty JSON object. | ## Options diff --git a/docs/features/contract/configuration.md b/docs/features/contract/configuration.md new file mode 100644 index 00000000..fda502d8 --- /dev/null +++ b/docs/features/contract/configuration.md @@ -0,0 +1,5 @@ +# Contract — Configuration + +The contract domain reads **no dedicated section of `.goga/config.yml`** — it consumes the global top-level `language` field (the default of `--lang`; see [Project Configuration](../../configuration/project.md)). + +The global `codemanifest:` section (named practices and agent annotations) belongs to no single domain and stays in the [Configuration](../../configuration/project.md#codemanifest) section. diff --git a/docs/features/contract/hooks.md b/docs/features/contract/hooks.md new file mode 100644 index 00000000..5acba8ef --- /dev/null +++ b/docs/features/contract/hooks.md @@ -0,0 +1,5 @@ +# Contract — Hooks + +The contract domain exposes **no hook actions** for tool packages today. + +The comparison is a read-only analysis over the project files and the parsed manifests. The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/contract/index.md b/docs/features/contract/index.md new file mode 100644 index 00000000..ee2767c5 --- /dev/null +++ b/docs/features/contract/index.md @@ -0,0 +1,18 @@ +# Contract + +Compare CODEMANIFEST declarations with the source code implementation. + +The contract domain is the drift detector between the DSL and the code. Which tasks it solves: + +- **Verify a cell** — `goga contract ` parses the cell's `CODEMANIFEST` and extracts the implementation surface from its source files: every declared entity and routine is matched against the actual classes and functions. +- **Per language** — extraction is language-aware (`--lang python | golang | kotlin | swift | javascript`, defaulting to the project's `language`); the per-language rules are covered in [Languages](../../languages/index.md). +- **Report the drift** — each declared type that the implementation does not match (missing, signature mismatch, misplaced) is reported; the report is what acceptance and review cycles act on. + +Together with [Lint](../lint/index.md) — which validates the DSL itself — this closes the loop: the manifest is structurally valid *and* faithfully implemented. + +## In this directory + +- [CLI](cli.md) — the full `goga contract` command reference +- [Configuration](configuration.md) — the domain reads the global `language` field +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.contract` package facade diff --git a/docs/features/history/api.md b/docs/features/history/api.md new file mode 100644 index 00000000..09eb2a66 --- /dev/null +++ b/docs/features/history/api.md @@ -0,0 +1,75 @@ +# History — API + +The facade of the domain package **`goga.history`** — the single owner of the `.goga/history/` tree. The git branch reader lives in the nested leaf cell `goga.history.git`, the status scale in `goga.history.statuses`; both are re-exported on this facade. + +The signatures below are the CODEMANIFEST contract of the cell. + +## Identity and addressing + +```python +normalize_topic_slug(name: str) -> str +current_year() -> str +``` + +The slug grammar — lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed (`Feature/Foo_Bar` → `feature-foo-bar`) — and the current year as four digits. + +```python +resolve_history_root() -> Path +resolve_topic_dir(topic: str, year: str | None = None) -> Path +resolve_topic_file(topic: str, filename: str, year: str | None = None) -> Path +topic_exists(topic: str, year: str | None = None) -> bool +ensure_topic_dir(name: str, year: str | None = None) -> Path +remove_topic_dir(name: str, year: str | None = None) -> bool +``` + +Topic addressing: the tree root, the topic directory, and an artifact file path (the filename taken verbatim, extension required). `ensure_topic_dir` creates the directory idempotently (parents as needed); `remove_topic_dir` removes it idempotently (`True` — removed). + +## Statuses + +```python +resolve_topic_status(topic_dir: Path, scale: StatusScale) -> list[str] +collect_topic_statuses(year: str | None = None, scale: StatusScale | None = None) -> list[TopicRecord] +TopicRecord(topic: str, statuses: list[str]) +``` + +The maximal present statuses of one topic and of one year's every topic — computed against the assembled scale, in scale order. + +```python +assemble_status_scale() -> StatusScale +StatusScale(stages: list[Stage]) +Stage(name: str, filepath: str, before: str | None = None, after: str | None = None) +StatusRegistry(builtin_stages: list[Stage], tool_prefix: str) +``` + +The scale assembly — the built-in artifact axis plus the registrations of installed tool packages (see [Hooks](hooks.md)). `assemble_status_scale` is the single entry point; a broken `goga_tool_*` import raises `ImportError`, an invalid registration surfaces as `ValueError`. + +## Tree traversal and cleanup + +```python +collect_history_tree(year: str | None = None) -> list[HistoryYear] +HistoryYear(year: str, topics: list[str]) +prune_topics(year: str | None = None, dry_run: bool = False) -> list[str] +``` + +`collect_history_tree` walks the tree (one year with `year`, every year with `None`). `prune_topics` deletes the orphan topics of the scoped year — the topics no branch of the repository inventory hosts — returning the removed slugs (`dry_run=True` lists without deleting). + +## Git embedding + +```python +resolve_current_branch_name() -> str | None +list_branch_refs() -> list[BranchRef] +BranchRef(name: str, ...) +``` + +The read-only branch surface: the current branch name (`None`-modes surface as `ValueError` reasons at the CLI layer) and the branch inventory used by the board, switching, and prune protection. + +## Example + +```python +from goga.history import collect_topic_statuses, resolve_topic_file + +for record in collect_topic_statuses("2026"): + print(record.topic, record.statuses) + +plan = resolve_topic_file("feat-x", "plan.md") # .goga/history/2026/feat-x/plan.md +``` diff --git a/docs/features/history/cli.md b/docs/features/history/cli.md new file mode 100644 index 00000000..9882c412 --- /dev/null +++ b/docs/features/history/cli.md @@ -0,0 +1,121 @@ +# goga history + +Work with the `.goga/history/` tree — its per-year topics, their statuses, and their paths. + +`goga history` is a Click group with five subcommands (`list`, `status`, `path`, `ensure`, `prune`) over the history domain. Everything is host-side; all of it is read-only except `ensure` (creates a directory) and `prune` (deletes topic directories). Domain errors surface as clean one-line errors (exit 1, no traceback). + +## Synopsis + +```bash +goga history [-y YEAR] list|status [-t TOPIC] [-s STATUS]|path [TOPIC] [-f FILENAME]|ensure [NAME]|prune [--dry-run] +``` + +## Year addressing + +The year is addressed by the group option `-y`/`--year` alone — given once, *before* the subcommand — and every subcommand reads the same value. The subcommands carry no year surfaces of their own: the removed forms are usage errors (exit 2) — a positional `YEAR` (`goga history status 2025`, `goga history prune 2025`) or a year option placed after the subcommand (`goga history path feat-x -y 2025`). + +- Without `-y`, `list` prints every year; `status`, `path`, `ensure`, and `prune` take the current year. +- An empty value (`-y ""`) counts as not set. +- The CLI does not validate the value — the domain owns the year semantics; a year missing from the tree is an empty result, not an error. + +```bash +goga history -y 2025 status +goga history -y 2025 path release/1.3.0 -f plan.md +goga history -y 2025 prune +``` + +## `goga history list` + +The inventory view: one `YYYY/` line per year, each topic indented under its year. With `-y`/`--year` the tree narrows to that year's section alone. + +``` +2025/ + └── release-1-3-0 +2026/ + └── feat-x + └── history-commands +``` + +An empty tree prints nothing. Read-only — statuses and artifact names never appear. + +## `goga history status` + +Prints the topics of one year, one `topic [status] [status] …` line each: + +``` +feat-x [planned] +release-1-3-0 [done] [mkdocs.published] +``` + +A topic carries its **maximal present statuses** in scale order — one bracketed segment per status: + +| Status | Artifact | Notes | +|---|---|---| +| `empty` | — | no artifact yet | +| `todo` | `todo.md` | written by the `--todo` editor entries (`goga topics create/switch --todo`, `goga pipeline … --todo`) | +| `defined` | `prd.md` | | +| `discovered` | `adr.md` | | +| `backlog` | `task.md` | | +| `designed` | `arch.md` | | +| `specified` | `design.md` | | +| `planned` | `plan.md` | | +| `done` | `completed/plan.md` | | + +A topic can carry several statuses at once: every artifact present that is outranked by no other present artifact stays visible (tool statuses included, shown qualified such as `mkdocs.published` — see [Tools](../tools/index.md) for how a tool package registers its own statuses). The year comes from the group's `-y`/`--year` (default: the current year) and is never printed; topics come out alphabetically. + +The status segments print colored (`cyan`) unless a non-empty `NO_COLOR` is set in the environment. + +### Filters + +- `-t`/`--topic` keeps the topics whose normalized slug contains the normalized filter as a substring. +- `-s`/`--status` is repeatable and keeps the topics carrying **at least one** of the requested statuses — any-of matching. Both built-in names (`-s planned`) and qualified tool names (`-s mkdocs.published`) are valid; an unknown name is a clean error before anything prints. +- Both filters combine by AND. An empty result prints nothing and exits 0 — it is not an error. + +```bash +goga history status # the current year, every topic +goga history -y 2025 status # one explicit year +goga history status -s planned # every topic carrying [planned] +goga history status -t release # every topic whose slug contains "release" +``` + +## `goga history path` + +Prints exactly one path of the history tree — and nothing else — for scripting: + +```bash +plan=$(goga history path -f plan.md) # the current year +plan=$(goga history -y 2025 path release/1.3.0 -f plan.md) # one explicit year +``` + +TOPIC defaults to the current git branch (taken raw, as a branch name or a slug — the two compose identically through the slug grammar). With `-f`/`--file` the artifact file path prints (the filename is taken verbatim and must carry an extension); otherwise the topic directory. The year comes from the group's `-y`/`--year` (default: the current one). Nothing is created on disk. + +## `goga history ensure` + +Creates the topic directory of the scoped year, idempotently: parents are created as needed and an existing directory is a success, not a conflict. The year comes from the group's `-y`/`--year` (default: the current one). NAME defaults to the current git branch. Prints nothing on stdout — the exit code carries the result. Only directories: no artifact file is created, and occupancy is not reported. + +## `goga history prune` + +Deletes the orphan topics of one year — the topics no branch of the repository inventory hosts — and prints one slug per line; an empty result prints nothing and exits 0. The year comes from the group's `-y`/`--year` (default: the current year) — only that year is touched. + +```bash +goga history prune --dry-run # the current year: list the candidates, delete nothing +goga history -y 2025 prune --dry-run +goga history -y 2025 prune # one explicit year +``` + +- A topic is protected when any local branch name, or the short name of any remote-tracking ref (the part after the first `/`), normalizes to the topic slug — in every year, not just the scoped one. A branch carrying the topic as merged work does not protect it. +- Deletion is unconditional — no status protects a topic, a `done` orphan goes too — and irreversible: the history tree is not in git, so a deleted topic directory cannot be recovered. Run with `--dry-run` first. +- Filesystem-only: no branch, ref, or index of git is touched — the only git call is the read-only ref listing. + +## Exit Codes + +| Code | Meaning | +|------|---------| +| `0` | Success — the tree, statuses, or path printed, the directory ensured, or the orphans pruned (possibly none) | +| `1` | A clean domain error: an unknown `-s` status name, an empty topic filter or slug, an undeterminable current branch where a topic default is needed, a broken `goga_tool_*` package failing to import during status-scale assembly, or a prune failure (a git failure of the ref listing, a missing git binary, a topic directory that cannot be deleted, or a directory name that normalizes to an empty slug) | +| `2` | A usage error (unknown option, too many arguments — including the removed year forms (a positional YEAR, a year option after the subcommand)) | + +## Notes + +- The topic slug grammar: lowercase, non-ASCII dropped, anything outside `[a-z0-9]` becomes `-`, repeat hyphens collapsed, edge hyphens trimmed (`Feature/Foo_Bar` → `feature-foo-bar`, `release/1.3.0` → `release-1-3-0`). +- `goga topics board` shows the same statuses across branches; `goga history status` shows the working copy of one year (see [topics](../topics/cli.md)). diff --git a/docs/features/history/configuration.md b/docs/features/history/configuration.md new file mode 100644 index 00000000..fa3614e5 --- /dev/null +++ b/docs/features/history/configuration.md @@ -0,0 +1,7 @@ +# History — Configuration + +The history domain reads **no section of `.goga/config.yml`** — it is configured by nothing. + +The tree root is fixed at `/.goga/history/`, the year defaults to the current one, and the status scale assembles from the built-in axis plus the registrations of installed tool packages (see [Hooks](hooks.md)) — none of it is configurable. + +The general configuration model of the product is covered in [Configuration](../../configuration/index.md). diff --git a/docs/features/history/hooks.md b/docs/features/history/hooks.md new file mode 100644 index 00000000..874d2c3c --- /dev/null +++ b/docs/features/history/hooks.md @@ -0,0 +1,35 @@ +# History — Hooks + +The history domain declares one hook action: the **status-scale registration** — the way an installed `goga_tool_*` package attaches its own statuses to the topic status scale, with no goga code changes. + +## The action + +| Address | Error class | Fires | +|---|---|---| +| `statuses` / `register_statuses` | **soft** — a failing hook is skipped with a stderr warning; the command continues | when a command first assembles the status scale (`goga history status`, `goga topics board`, …) | + +A tool subscribes inside its `register_hooks` callback: + +```python +# inside the goga_tool_ package +def register_hooks(hooks): + hooks.subscribe("statuses", "register_statuses", "published", register_published) + + +def register_published(context): + context.register("published", "mkdocs/published.md", after="planned") +``` + +The hook receives `context` — the registration surface scoped to the tool. Every registered name is stored **qualified** with the tool prefix (`.`, e.g. `mkdocs.published`), so registrations from different tools never collide and a topic can carry several statuses at once. A hook may also declare `self` — the isolated per-tool context of the run. + +## The registration surface + +- `name` — the status name as the tool defines it; shown as `.`. +- `filepath` — the artifact path relative to the topic directory; nested paths allowed. +- `before` / `after` — anchors: qualified names of statuses this one precedes or follows. At least one anchor is required; both given define a placement range. +- The built-in statuses are immutable — registration is add-only. +- Two tools may reference the same artifact path — both statuses apply independently. + +A registration missing an anchor, carrying empty values, an unresolvable anchor, or an invalid range is skipped with a stderr warning naming the tool, the action, and the reason — it never aborts the command and never cancels other registrations. + +The platform mechanism behind the action (enumeration, the registry, delivery, inspection with `goga hooks`) is the [Hooks](../hooks/index.md) domain; the tool-package side of authoring a `register_hooks` callback is covered in [Tools — Hooks](../tools/hooks.md). diff --git a/docs/features/history/index.md b/docs/features/history/index.md new file mode 100644 index 00000000..d64a756c --- /dev/null +++ b/docs/features/history/index.md @@ -0,0 +1,19 @@ +# History + +The `.goga/history/` tree — one directory per topic per year, carrying the artifacts of the work. + +The history domain is the single owner of the tree: it answers *where the work's artifacts live and in what state they are*. Which tasks it solves: + +- **Address artifacts** — every piece of work has a topic directory `.goga/history///`; the domain resolves directory and artifact-file paths, checks existence, creates directories idempotently, and removes them. +- **See the state** — each artifact that lands (`todo.md`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, `completed/plan.md`) deepens the topic's status on the built-in scale `empty → todo → defined → discovered → backlog → designed → specified → planned → done`. `goga history status` prints the maximal present statuses of one year; tool packages extend the scale with their own qualified statuses. +- **Browse the tree** — `goga history list` prints the inventory (years and topics); `goga history path` prints exactly one path for scripting. +- **Clean up** — `goga history prune` deletes the orphan topics of a year (topics no branch hosts anymore). + +The tree is meant to stay out of git — add `.goga/history/` to your `.gitignore`. The cross-branch view of the same statuses (the board) is the [Topics](../topics/index.md) domain. + +## In this directory + +- [CLI](cli.md) — the full `goga history` command reference +- [Configuration](configuration.md) — the domain reads no configuration section +- [Hooks](hooks.md) — the `statuses` action: how tool packages extend the status scale +- [API](api.md) — the `goga.history` package facade diff --git a/docs/features/hooks/api.md b/docs/features/hooks/api.md new file mode 100644 index 00000000..beaeff69 --- /dev/null +++ b/docs/features/hooks/api.md @@ -0,0 +1,73 @@ +# Hooks — API + +The facade of the domain package **`goga.hooks`** — the extension surface of the goga domains for installed tool packages. The facade declares no type of its own: it re-exports the declared action catalog, the run registry with its per-tool inspection view, and the emission of an action at a domain checkpoint. Importing the package imports no tool package and enumerates nothing. + +The signatures below are the CODEMANIFEST contract of the platform cells. + +## The facade + +```python +from goga.hooks import HookRegistry, ToolHooks, declared_actions, emit_hook_event +``` + +| Name | Origin | Purpose | +|---|---|---| +| `declared_actions()` | `goga.hooks.catalog` | The declared action catalog | +| `HookRegistry()`, `ToolHooks` | `goga.hooks.registry` | The run registry and its per-tool view | +| `emit_hook_event(...)` | `goga.hooks.dispatch` | The emission of an action at a domain checkpoint | + +## The action catalog + +```python +declared_actions() -> list[Action] +Action(domain: str, name: str, error_class: str) +``` + +Every action declared by the domains — its address (`domain`, `name`) and its error class (`soft` or `hard`). A domain checkpoint consults the catalog to validate the address before emitting. + +## The registry + +```python +HookRegistry() +ToolHooks(tool: str, subscriptions: list[Subscription], rejections: list[RejectedRegistration]) +ToolContext(tool: str) +``` + +`HookRegistry` is the one-registry-per-run state: one `ToolHooks` entry per tool with registrations, carrying its subscriptions and its refused registrations (with reasons). `ToolContext` is the isolated per-tool context delivered as `self` to the tool's hooks. + +The registration envelope lives in the tools leaf cell: + +```python +HookRegistrar(tool: str) +Subscription(tool: str, domain: str, action: str, name: str, hook: Callable) +RejectedRegistration(tool: str, domain: str, action: str, name: str, reason: str) +enumerate_tool_packages() -> list[ToolPackage] +ToolPackage(module_name: str) +call_register_hooks(package: ToolPackage, registrar: HookRegistrar) -> bool +``` + +`HookRegistrar` is the `hooks` object delivered to a package's `register_hooks` callback — `subscribe(domain, action, name, hook)` registers one hook; a wrong address, an empty name, or a repeated name on the same address is refused with a recorded reason. `enumerate_tool_packages` discovers the installed `goga_tool_*` packages deterministically (alphabetical order of top-level module name); `call_register_hooks` invokes a package's callback (`True` — the package declares one). + +## The emission + +```python +emit_hook_event(registry: HookRegistry, domain: str, action: str, context_for: Callable) +wrap_context(target: object) -> object +build_hook_arguments(hook: Callable, context: object, self_context: ToolContext) -> dict[str, object] +``` + +`emit_hook_event` delivers an action to every subscribed hook — assembling the registry on first use. The context mediation: `wrap_context` makes the delivered object read-only (attribute assignment blocked); `build_hook_arguments` passes a hook only the values it declares by the offered names (`context`, `self`). + +## Example + +```python +from goga.hooks import HookRegistry, declared_actions, emit_hook_event + +registry = HookRegistry() + +for action in declared_actions(): + print(action.domain, action.name, action.error_class) + +# a domain checkpoint — the scale assembly of the history domain +emit_hook_event(registry, "statuses", "register_statuses", context_for=make_registry_surface) +``` diff --git a/docs/features/hooks/cli.md b/docs/features/hooks/cli.md new file mode 100644 index 00000000..159dd003 --- /dev/null +++ b/docs/features/hooks/cli.md @@ -0,0 +1,69 @@ +# goga hooks + +Inspect the hooks registered by the installed tool packages. + +## Synopsis + +```bash +goga hooks [--tool NAME]... +``` + +## Description + +`goga hooks` assembles the run registry once — it imports every installed `goga_tool_*` package and runs its `register_hooks` callback — and prints the registrations as a tree: tool, then domain, then action. It states the **fact of registration**, never whether a hook ran in a particular command. See [Hooks — the registration contract](hooks.md) for the registration contract. + +## The tree + +```text +$ goga hooks +mkdocs + statuses + register_statuses published + register_statuses sync +scriba + statuses + register_statuses published + rejected statuses/register_statuses "dup": repeated name on the same address +``` + +- One tool line per tool with registrations — the tool identity, the package name with the `goga_tool_` prefix dropped and underscores turned into hyphens. There is no root line: the tools are the top level. +- Under a tool, one domain line per distinct domain of its subscriptions, ordered alphabetically. +- Under a domain, one line per subscription — the action name and the hook name. +- Every refused registration prints with its reason. +- An empty registry prints nothing and exits `0`. + +## The slice + +`-t`/`--tool` narrows the tree to the named tools. The option is repeatable; the name is the tool identity — without the `goga_tool_` prefix — as the tool line of the tree shows it. A requested tool without registrations keeps its entry with an empty list; an unknown name is not an error. + +```bash +goga hooks --tool my-tool +goga hooks -t my-tool -t other-tool +``` + +## Behavior + +- The registry assembles once at the start of the command; it is never cached between runs — package edits apply from the next run, without reinstall. +- A broken package import fails the command with a clean error naming the package (stderr, exit `1`, no traceback). +- Commands that use no hooks never build the registry and never enumerate the packages; only this command and the hook checkpoints do. + +## Exit Codes + +| Code | Meaning | +|------|--------------------------------------| +| `0` | Success — an empty registry included | +| `1` | Error — a broken package import | + +## Examples + +List every registered hook of the environment: + +```bash +goga hooks +``` + +Inspect one tool only: + +```bash +goga hooks --tool mkdocs +``` diff --git a/docs/features/hooks/configuration.md b/docs/features/hooks/configuration.md new file mode 100644 index 00000000..398e2f88 --- /dev/null +++ b/docs/features/hooks/configuration.md @@ -0,0 +1,5 @@ +# Hooks — Configuration + +The hooks domain reads **no section of `.goga/config.yml`** — it is configured by nothing. + +The registry assembles from the `goga_tool_*` packages installed in the running interpreter (see [Install](../install/index.md)); its shape is fully derived from their `register_hooks` callbacks. The general configuration model of the product is covered in [Configuration](../../configuration/index.md). diff --git a/docs/features/hooks/hooks.md b/docs/features/hooks/hooks.md new file mode 100644 index 00000000..9b41d907 --- /dev/null +++ b/docs/features/hooks/hooks.md @@ -0,0 +1,40 @@ +# Hooks — The registration contract + +How a `goga_tool_*` package subscribes its hooks to domain actions — the tool-author side of the platform. + +A tool **may** expose a `register_hooks(hooks)` callable in its facade package — the registration of domain hooks. goga calls it when a command first reaches a hook checkpoint of the run, or when you inspect the registry with [`goga hooks`](cli.md); commands that use no hooks never call it. Registration is never cached — package edits apply from the next run, without reinstall. + +```python +# inside the goga_tool_ package +def register_hooks(hooks): + hooks.subscribe("statuses", "register_statuses", "published", register_published) + + +def register_published(context): + context.register("published", "mkdocs/published.md", after="planned") +``` + +`hooks.subscribe(domain, action, name, hook)` registers one hook: + +- `domain` + `action` — the action address: the semantic owner domain and the action name within it (`"statuses"` / `"register_statuses"` is the topic-status action — see [History — Hooks](../history/hooks.md)). +- `name` — the hook name, unique per tool per address; registrations appear in the [`goga hooks`](cli.md) tree under their tool line. +- `hook` — the callable executed when the action fires. + +The tool identity is assigned by goga from the package name — a package never names itself, and identical hook names of different tools never collide. Enumeration is deterministic: packages in alphabetical order of top-level module name, subscriptions delivered in enumeration order. + +## The hook signature + +A hook receives values only for the parameters it declares by the fixed offered names — `context` and `self`: + +- `context` — the delivered object of the action. Read attributes and call methods freely; attribute assignment is blocked. What the object carries is fixed by the owner domain's contract — for `register_statuses` it is the status registration surface (`register(name, filepath, before=..., after=...)`, names stored qualified `.`; see [History — Hooks](../history/hooks.md) for the scale rules). +- `self` — the isolated context of your tool. One instance links all its hook invocations of a run; freely mutable by your tool, invisible to the domains. + +The declaration order does not matter; names you did not declare receive nothing. + +## Error classes and diagnostics + +Each action in the catalog fixes how a failing hook is treated. The topic-status action is **soft**: a failing hook is skipped with a stderr warning naming the tool, the action, and the reason, and the command continues. A **hard** action stops the command at the first failing hook with a clean error — the class is chosen by the owner domain when it declares the action. + +At registration: a wrong address, an empty name, or a repeated name on the same address is refused with a stderr warning naming the tool, the action, and the reason — the registration is skipped, the rest apply. A crashing callback is a warning; the registrations made before the crash survive. A broken package import is the only fatal case: a clean error naming the package. + +> **Migration note.** The old `register_topic_statuses(statuses)` callback is gone. After a goga update, a package still carrying it loses its statuses **without any diagnostic** — they silently disappear from the scale. Moving to `register_hooks` is the package author's responsibility. Qualified names of packages with underscores in their name change too: the qualifier is the canonical hyphen identity, so `goga_tool_hello_world` now registers `hello-world.published` where it used to register `hello_world.published` — existing `goga history status -s .` filters must use the hyphen form. diff --git a/docs/features/hooks/index.md b/docs/features/hooks/index.md new file mode 100644 index 00000000..835097dc --- /dev/null +++ b/docs/features/hooks/index.md @@ -0,0 +1,25 @@ +# Hooks + +The extension platform connecting goga domains and tool packages. + +The hooks domain is the mechanism behind every domain extension: a domain declares an **action** at a checkpoint of its run; an installed `goga_tool_*` package subscribes a hook to that action; when a command first reaches the checkpoint, goga enumerates the tool packages, calls each `register_hooks` callback, and delivers the action's context to the subscribed hooks. Which tasks it solves: + +- **Domains expose extension points without knowing their consumers** — a domain declares an action address and the error class; the platform owns the enumeration, registration, and delivery. +- **Tool packages extend domains with no goga code changes** — a package registers its hooks at run time; registration is never cached, so package edits apply from the next run without reinstall. +- **Inspection** — `goga hooks` assembles the registry once and prints it as a tree: tool, domain, action — the fact of registration, including every refused registration with its reason. + +The declared actions today: the status-scale registration of the [History](../history/hooks.md) domain. The authoring side — how a tool package writes its `register_hooks` callback — is the [registration contract](hooks.md). + +## Model + +- **Action** — an address `domain / name` plus an error class (**soft**: a failing hook is skipped with a warning; **hard**: the command stops at the first failure). The owner domain chooses the class when it declares the action. +- **Registry** — one per run, assembled lazily on first use at a checkpoint: packages in alphabetical order of top-level module name, subscriptions delivered in enumeration order. Commands that use no hooks never build the registry. +- **Tool identity** — assigned by goga from the package name (the `goga_tool_` prefix dropped, underscores as hyphens); a package never names itself, and identical hook names of different tools never collide. +- **Delivery** — a hook receives values only for the parameters it declares by the fixed offered names (`context`, `self`); the context is read-only (attribute assignment blocked), `self` is the tool's isolated per-run state. + +## In this directory + +- [CLI](cli.md) — the `goga hooks` command reference +- [Configuration](configuration.md) — the domain reads no configuration section +- [Hooks](hooks.md) — the registration contract for tool-package authors +- [API](api.md) — the `goga.hooks` package facade diff --git a/docs/features/index.md b/docs/features/index.md new file mode 100644 index 00000000..c3cc33d7 --- /dev/null +++ b/docs/features/index.md @@ -0,0 +1,38 @@ +# Features + +The functional domains of goga — one directory per domain, five pages per domain. + +A **domain** is a user-facing functional area of the product: what it solves, how it is configured, which CLI commands drive it, which hook points it offers to tool packages, and which Python API its package facade exposes. Internal machinery (the AST, the pipeline compiler, contract extraction) lives in [Architecture](../architecture/index.md) and [Languages](../languages/index.md); the DSL itself is covered in [Cell](../cell/index.md). + +## The domains + +| Domain | What it solves | CLI | +|---|---|---| +| [Topics](topics/index.md) | Organizing work: branches, the board, todo entries, creation, switching, deletion, publication | `goga topics` | +| [History](history/index.md) | The `.goga/history/` artifact tree, the status scale, orphan cleanup, scriptable paths | `goga history` | +| [Pipelines](pipelines/index.md) | Running agent-driven cycles: pipeline-files, workflows, shipped pipelines | `goga pipeline` | +| [Build](build/index.md) | Executing build plans through a ralph-loop in a container | `goga build` | +| [Tools](tools/index.md) | The tool ecosystem: using, packaging, and naming `goga-tool` packages | `goga tool` | +| [Connect](connect/index.md) | Installing goga skills and commands into AI agents | `goga connect` | +| [Upgrade](upgrade/index.md) | Upgrading goga (and tools) with agent re-sync | `goga upgrade` | +| [Install](install/index.md) | Installing and removing tool packages into the running interpreter | `goga install`, `goga uninstall` | +| [Init](init/index.md) | Interactive project initialization and template scaffolding | `goga init` | +| [Usages](usages/index.md) | Syncing cell-level usages from declared git dependencies | `goga usages` | +| [Schema](schema/index.md) | JSON schema trees from CODEMANIFEST files | `goga schema` | +| [Contract](contract/index.md) | Comparing CODEMANIFEST declarations with the implementation | `goga contract` | +| [Hooks](hooks/index.md) | The extension platform connecting domains and tool packages | `goga hooks` | +| [Lint](lint/index.md) | Validating CODEMANIFEST files | `goga lint` | + +## The page model + +Every domain directory carries the same five pages: + +| Page | Content | +|---|---| +| **Overview** (`index.md`) | The functional area — which tasks the domain solves, its model and boundaries | +| **CLI** (`cli.md`) | The full normative command reference: synopsis, options, behavior, exit codes | +| **Configuration** (`configuration.md`) | The `.goga/config.yml` sections the domain reads (or a statement that it reads none) | +| **Hooks** (`hooks.md`) | The hook points the domain offers to tool packages (or a statement that it offers none) | +| **API** (`api.md`) | The facade API of the domain's Python package: types, signatures, parameters, purpose, usage examples | + +The command reference for the whole product — one table, every command mapped to its domain — is kept in the [CLI](../cli/index.md) cross-road. diff --git a/docs/features/init/api.md b/docs/features/init/api.md new file mode 100644 index 00000000..66b601d9 --- /dev/null +++ b/docs/features/init/api.md @@ -0,0 +1,35 @@ +# Init — API + +The facade of the domain package **`goga.onboarding`** — the interactive project initialization and template scaffolding. + +The signatures below are the CODEMANIFEST contract of the cell. + +```python +InitLogic(questionnaire: Questionnaire, generator: FileGenerator) +Questionnaire() +FileGenerator() +``` + +- `InitLogic` — the orchestration: run the questionnaire, resolve the answers (template answers first, the interactive dialogue for what the template left open), and generate the project files. +- `Questionnaire` — the interactive dialogue — the questions behind `.goga/config.yml` and the optional Dockerfile. +- `FileGenerator` — the file materialization: `.goga/config.yml`, the Dockerfile, and the template scaffold application (a copier template with `goga init --upgrade` migrates an existing scaffold). + +```python +InitAnswers(goga_config: GogaConfigAnswers | None = None) +GogaConfigAnswers(language: str, image: str, agent: str | None, + pipeline_agent: str | None, pipeline_env: dict | None, + env: dict | None, codemanifest_usages: dict | None, + codemanifest_annotations: str | None, + dockerfile_path: str | None, dockerfile_base_image: str | None) +``` + +The resolved answers: the project language and image, the build/pipeline agent settings with their env layers, the `codemanifest` section values, and the optional Dockerfile pair (path + base image). `InitAnswers` with `goga_config=None` — a template answered everything. + +## Example + +```python +from goga.onboarding import FileGenerator, InitLogic, Questionnaire + +logic = InitLogic(questionnaire=Questionnaire(), generator=FileGenerator()) +logic.run() +``` diff --git a/docs/cli/init.md b/docs/features/init/cli.md similarity index 93% rename from docs/cli/init.md rename to docs/features/init/cli.md index 5497be01..1046fc78 100644 --- a/docs/cli/init.md +++ b/docs/features/init/cli.md @@ -48,7 +48,7 @@ The wizard proceeds through the following steps in order. **The entire survey is 4. **Codemanifest Annotations** -- Add custom annotations (global directives for the AI agent) that will be stored in the configuration. -5. **Build Agent** -- Confirm-gated (defaults to **No**). Decline to skip configuring a build agent (the `agent` key is then omitted from the generated config; `goga build` raises a clean `ClickException` if it later needs one). Accept to select an AI executor: `claude`, `codex`. +5. **Build Agent** -- Confirm-gated (defaults to **No**). Decline to skip configuring a build agent (the `agent` key is then omitted from the generated config; `goga build` raises a clean `ClickException` if it later needs one). Accept to select an AI executor: `claude`, `codex`, `cursor`, `opencode`, or `qwen`. 6. **Custom Dockerfile** -- Optionally create a custom Dockerfile. When accepted, the suggested path is `.goga/Dockerfile` (saved inside the project-scoped `.goga/` directory); press Enter to accept it or type a different path. The Dockerfile decision drives the next step (image semantics differ). @@ -61,15 +61,15 @@ The wizard proceeds through the following steps in order. **The entire survey is | Language | Images | |---|---| - | python | `qarium/goga-python-3.10:1.2` ... `qarium/goga-python-3.14:1.2` | - | golang | `qarium/goga-golang-1.23:1.2` ... `qarium/goga-golang-1.26:1.2` | - | javascript | `qarium/goga-node-22:1.2`, `qarium/goga-node-24:1.2` | - | kotlin | `qarium/goga-kotlin-2.0:1.2` ... `qarium/goga-kotlin-2.3:1.2` | - | swift | `qarium/goga-swift-6.0:1.2` ... `qarium/goga-swift-6.2:1.2` | + | python | `qarium/goga-python-3.10:1.3` ... `qarium/goga-python-3.14:1.3` | + | golang | `qarium/goga-golang-1.23:1.3` ... `qarium/goga-golang-1.26:1.3` | + | javascript | `qarium/goga-node-22:1.3`, `qarium/goga-node-24:1.3` | + | kotlin | `qarium/goga-kotlin-2.0:1.3` ... `qarium/goga-kotlin-2.3:1.3` | + | swift | `qarium/goga-swift-6.0:1.3` ... `qarium/goga-swift-6.2:1.3` | 8. **Environment Variables** -- Configure environment variables for the build. Suggested keys are offered per agent (e.g., `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` for Claude; `CODEX_MODEL` for Codex). You can also add arbitrary custom variables. -9. **Pipeline Agent** -- Confirm-gated (defaults to **No**). Decline to skip configuring a pipeline agent (the `pipeline.agent` key is omitted; a per-stage workflow agent or afm's own default then covers the absent global agent). Accept to select an AI executor: `claude`, `codex`. Does **not** inherit the build agent from step 5 — build and pipeline are collected via independent confirm-gates, so they can diverge or both be left unset. +9. **Pipeline Agent** -- Confirm-gated (defaults to **No**). Decline to skip configuring a pipeline agent (the `pipeline.agent` key is omitted; a per-stage workflow agent or afm's own default then covers the absent global agent). Accept to select an AI executor: `claude`, `codex`, `cursor`, `opencode`, or `qwen`. Does **not** inherit the build agent from step 5 — build and pipeline are collected via independent confirm-gates, so they can diverge or both be left unset. 10. **Pipeline Environment Variables** -- Configure environment variables for the pipeline container. Suggested keys are offered per agent (same shape as step 8). You can also add arbitrary `KEY=VALUE` variables. Omitted entirely when nothing is collected. diff --git a/docs/features/init/configuration.md b/docs/features/init/configuration.md new file mode 100644 index 00000000..c1c629b9 --- /dev/null +++ b/docs/features/init/configuration.md @@ -0,0 +1,5 @@ +# Init — Configuration + +The init domain reads **no section of `.goga/config.yml`** — it **writes** the file: the questionnaire's answers become the initial `language`, `image`, `build`, and `codemanifest` values (and, optionally, a project Dockerfile). + +What each written field means afterwards is covered by the domains that read it — see [Project Configuration](../../configuration/project.md) and the per-domain [Configuration](../index.md#the-page-model) pages. diff --git a/docs/features/init/hooks.md b/docs/features/init/hooks.md new file mode 100644 index 00000000..83ded91f --- /dev/null +++ b/docs/features/init/hooks.md @@ -0,0 +1,5 @@ +# Init — Hooks + +The init domain exposes **no hook actions** for tool packages today. + +Initialization is a one-time interactive flow over the goga assets; the tool packages enter the project afterwards — through [Install](../install/index.md) and [Connect](../connect/index.md). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/init/index.md b/docs/features/init/index.md new file mode 100644 index 00000000..d6fd8cda --- /dev/null +++ b/docs/features/init/index.md @@ -0,0 +1,16 @@ +# Init + +Interactive project initialization, with optional template scaffolding. + +The init domain turns an empty directory into a goga project. Which tasks it solves: + +- **Configure a project** — `goga init` walks an interactive questionnaire: the language, the container image, the agents, the initial `.goga/config.yml` sections, the optional Dockerfile — and writes `.goga/config.yml` (plus the Dockerfile) from the answers. +- **Scaffold from a template** — `goga init ` starts from a [copier](https://copier.readthedocs.io/) repo template (optionally pinned with `#ref` or `--ref`) and then asks only the questions the template left open. +- **Migrate a scaffolded project** — `goga init --upgrade` migrates an existing scaffolded project to the current generator version. + +## In this directory + +- [CLI](cli.md) — the full `goga init` command reference +- [Configuration](configuration.md) — the domain writes (not reads) the configuration +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.onboarding` package facade diff --git a/docs/features/install/api.md b/docs/features/install/api.md new file mode 100644 index 00000000..33f55aea --- /dev/null +++ b/docs/features/install/api.md @@ -0,0 +1,35 @@ +# Install — API + +The facade of the domain package **`goga.commands.install`** — the pip install/uninstall of tool packages with the post-install hooks and agent activation. + +The signatures below are the CODEMANIFEST contract of the cell. + +```python +install(ctx: click.Context, name: str | None, sudo: bool, version: str | None, + local: str | None, no_connect: bool = False) -> int +uninstall(ctx: click.Context, name: str, sudo: bool = False, yes: bool = False, + target_user: str | None = None) -> int +``` + +- `install` — pip-install a tool into the running interpreter (single mode with `name`, local mode with `local`, bulk mode with neither — the `tools:` declarations), then run the post-install hooks and activate the registered agents (skipped with `no_connect`). Returns the exit code. +- `uninstall` — remove exactly one tool package after the confirmation (`yes` skips it; a non-interactive terminal without `yes` is a clean error), then re-sync the agents. `sudo` targets a system-Python install; `target_user` re-syncs another user's installation. + +```python +resolve_initiating_user() -> str +run_install_hooks(tools: list[str]) -> None +call_install_hook(tool: str, user: str) -> bool +``` + +- `resolve_initiating_user` — the initiating user of the run (`SUDO_USER` under sudo, else the current OS user) — the value passed to keyword-capable hooks. +- `run_install_hooks` — run the post-install hook of each named tool (the pip-fresh set); a failing hook raises. +- `call_install_hook` — invoke one tool's `install` facade callable (`True` — the package declares one and it ran). + +## Example + +```python +import click +from goga.commands.install import install + +exit_code = install(click.get_current_context(), name="mkdocs", sudo=False, + version="1.0.x", local=None, no_connect=False) +``` diff --git a/docs/features/install/cli.md b/docs/features/install/cli.md new file mode 100644 index 00000000..5ebf422e --- /dev/null +++ b/docs/features/install/cli.md @@ -0,0 +1,168 @@ +# goga install + +`goga install` adds goga-tool packages into the **current runtime interpreter** — the exact Python that runs goga. It targets the running interpreter's pip directly, so the install lands in the correct environment regardless of how goga was deployed (pipx venv, system Python, or any other). + +After a successful pip in single, local, or bulk mode, the command runs each freshly installed tool's optional **post-install hook** (see [Post-install hooks](#post-install-hooks)), then **activates** every agent already recorded in `~/.goga/connect.yml` (re-syncing each with its persisted `force_overwrite`) so the freshly installed tool's skills and pipelines appear in `~/.goga/` and in each connected agent's symlink tree. Pass `--no-connect` to skip activation and perform the install only (useful in CI/Docker where a transient activation failure must not fail the install); the post-install hooks still run. To execute an installed tool without going through an agent, run [`goga tool`](../tools/cli.md). + +## Modes + +`goga install` branches on whether a tool name or `--local` path is given: + +- **Single mode** (`goga install `): install one tool, run its post-install hook, then activate. `--version` resolves through the four-form grammar; the project config is ignored. +- **Local mode** (`goga install --local [:]` / `-l [:]`): pip-install a local directory (no PyPI lookup). Mutually exclusive with `name`; `--version` is rejected. The optional `:` suffix names the tool whose post-install hook runs; without it no hook runs (a warning is logged). Activation follows the single/bulk rules. +- **Bulk mode** (`goga install`): install every tool declared in the `tools` section of `.goga/config.yml`, in a single pip invocation in YAML insertion order, then one activation pass. +- **Empty mode** (`goga install` with no `tools` section): no-op — prints `Nothing to install` and exits 0. pip is not invoked, and neither is activation. + +## Usage + +### Single mode + +```bash +# Plain install — current user, latest version +goga install foo + +# Install a specific concrete version +goga install foo --version 1.0.1 + +# Install within a minor x-range (>=1.0.0, <1.1.0) +goga install foo --version 1.0.x + +# Install within a major x-range (>=1.0.0, <2.0.0) +goga install foo --version 1.x + +# Pin to latest explicitly (same as omitting --version) +goga install foo --version latest + +# Install with sudo (system-Python installs requiring root); activation runs +# without sudo against the preserved $HOME +goga install foo --sudo + +# Install only — skip activation (escape-hatch for CI/Docker) +goga install foo --no-connect +``` + +### Bulk mode + +Declare tools in `.goga/config.yml`: + +```yaml +tools: + viewer: latest # → no specifier (pip selects newest) + afm: 1.0.x # → ~=1.0.0 (minor x-range, >=1.0.0,<1.1.0) + ralphex: 1.x # → ~=1.0 (major x-range, >=1.0.0,<2.0.0) + go: 1.0.1 # → ==1.0.1 (concrete pin) +``` + +Then install them all in a single pip invocation: + +```bash +# Install every declared tool under the current user +goga install + +# Same, but under sudo with HOME preserved +goga install --sudo + +# Bulk install only, no activation +goga install --no-connect +``` + +Bulk mode issues **exactly one** `pip install` whose argv contains every resolved `goga-tool-` in YAML order, followed by one activation pass. This lets pip's resolver see the whole set together under `-U`, avoiding dependency drift between sequential installs. + +### Local mode + +Install a pip-installable local directory instead of resolving a package from PyPI: + +```bash +# Install a tool from a local source checkout. +# No hook runs — a warning is logged +goga install --local ./my-tool + +# Same path with the : suffix — the post-install hook of +# goga_tool_mytool runs after pip +goga install --local ./my-tool:mytool + +# Short alias +goga install -l ./my-tool:mytool + +# Local install only, no activation; the hook still runs (suffix present) +goga install --local ./my-tool:mytool --no-connect + +# Under sudo (pip only; hooks and activation never use sudo) +goga install --local ./my-tool:mytool --sudo +``` + +Local mode issues a single `pip install -U` (never `-e`/editable) so the directory is installed the same way a named package is. The **first** colon separates path from tool name: `./my-tool:mytool` installs `./my-tool` and runs the hook of `goga_tool_mytool`. A malformed suffix (an empty tool name, a path separator, or a second colon — e.g. a Windows drive path misread as a suffix) exits 1 before any pip. pip owns the missing-path error — there is no CLI-level existence check, so an invalid path surfaces as pip's own failure with its verbatim return code. The `name` positional and `--local` are mutually exclusive, and `--version` is rejected in local mode (both exit 1 with a clear message). The project config is **not** read in local mode; activation follows the single/bulk rules. + +## Options + +| Option | Type | Default | Purpose | +|---|---|---|---| +| `name` (positional, optional) | string | None | Tool name without the `goga-tool-` prefix. When absent, bulk/empty mode runs from `.goga/config.yml`. | +| `--sudo` | flag | False | Run pip under `sudo --preserve-env=HOME` (Unix-only). Applies to pip only; activation never uses sudo. Applies to single, local, and bulk modes. | +| `--version `, `-v ` | string | None | Version form in the four-form grammar. Used by single mode only; ignored in bulk mode. Both forms are aliases on the same option — `-v 1.0.x` is identical to `--version 1.0.x`. | +| `--local [:]`, `-l [:]` | string | None | Path to a pip-installable local directory, optionally followed by `:` — the name of the tool whose post-install hook runs (local mode). Mutually exclusive with `name`; `--version` is rejected in local mode. Without the suffix no hook runs. | +| `--no-connect` | flag | False | Skip post-install agent activation only. Post-install hooks still run after a successful pip; a failing hook exits 1 even with this flag. | + +## Post-install hooks + +After a successful pip in single mode, local mode with a `:` suffix, and bulk mode, the command imports each freshly installed tool's facade module `goga_tool_` (hyphens and dots become underscores, lowercased) and calls its `install` callable when one exists: + +- No facade module or no callable `install` → quiet skip (the hook is optional; tools without one install exactly as before). +- The hook's signature declares a keyword-capable `user` parameter → called as `install(user=)`; otherwise called with no arguments. +- The initiating user is `SUDO_USER` when goga itself runs under sudo (`sudo goga install ...`), otherwise the current OS user — the actual person, not root. The `--sudo` flag runs only pip under sudo and does not set `SUDO_USER`. +- Hook failure (an exception from the hook body): exit 1 with the tool name and the hook message; the pip package stays installed (no rollback) and activation does not run. In bulk mode the sequence stops at the first failing hook — the remaining tools' hooks are not called. +- Hooks run before activation; `--no-connect` suppresses only the activation. +- Hooks are not run by `uninstall` or `upgrade`. + +A tool with a hook looks like this: + +```python +# inside the goga_tool_mytool facade package +def install(user: str | None = None) -> None: + ... # tool-owned setup; `user` receives the initiating user + # only when the parameter is declared keyword-capable +``` + +## Post-install activation + +When pip succeeds in single, local, or bulk mode and `--no-connect` is not set, the command activates every agent listed in `~/.goga/connect.yml`, each with its own recorded `force_overwrite`. Activation is a local operation on `$HOME` and **never** runs under `--sudo` — only pip honors `--sudo`. A missing or empty registry is a no-op that returns 0: the tool is installed on the interpreter but not yet linked to any agent. Connect an agent later with `goga connect ` and the tool will be picked up on the next install/upgrade. + +## Version form grammar + +`goga install` emits the operator. The four accepted forms resolve to pip specifiers as follows: + +| Form | Example | Resolved pip specifier | Semantics | +|---|---|---|---| +| Minor x-range | `1.0.x` | `~=1.0.0` | PEP 440 compatible release: `>=1.0.0,<1.1.0` | +| Major x-range | `1.x` | `~=1.0` | PEP 440 compatible release: `>=1.0.0,<2.0.0` | +| Concrete | `1.0.1` | `==1.0.1` | Exact pin | +| Latest marker | `latest` | *no specifier* | pip selects newest under `-U` | + +The following forms are **rejected** with exit code 1 and a clear error: + +| Rejected form | Reason | +|---|---| +| `==1.0`, `>=1.0`, `~=1.0`, `<2.0`, `!=1.0` | Operator-prefixed — write the grammar form instead; the command emits the operator | +| `foo`, `1.x.0`, `1.0.x.y` | Malformed — not in the four-form grammar | +| YAML-null `tools` value (e.g. `viewer:`) | Structural type error in the loader — write `latest` explicitly | + +## Exit codes + +| Exit code | Condition | +|---|---| +| 0 | pip and hooks succeeded and activation succeeded (or the registry is missing/empty), or empty mode no-op | +| non-zero (pip) | pip failed — its returncode propagated verbatim, with no translation; hooks and activation are not run | +| 1 | a post-install hook raised, or the hook step itself failed (e.g. the initiating user could not be resolved): the tool name and hook message go to stderr; the pip package stays, activation does not run, and bulk stops at the first failing hook | +| non-zero (activation) | pip and hooks succeeded but activation failed for one or more agents — the first non-zero per-agent failure is returned | +| 1 | a version form was rejected, `name` and `--local` were both given, `--version` was given with `--local`, the `--local` tool-name suffix is malformed, `.goga/config.yml` could not be loaded in bulk/empty mode, or the pip/sudo executable could not start | + +With `--no-connect`, pip's returncode is the exit code once pip and the post-install hooks have succeeded (install-only semantics); a failing hook still exits 1. + +## Notes + +- The current interpreter (`sys.executable`) must be the one where goga is installed. +- The caller needs write access to the site-packages directory (or pass `--sudo`) and, for activation, to `~/.goga/`. +- For bulk mode, `.goga/config.yml` must exist and contain a `tools` section (otherwise empty mode runs — a no-op, not an error). +- On Windows, `--sudo` is unavailable (sudo is Unix-only). +- Do not bypass this command and call `pip` with `sudo` directly without `--preserve-env=HOME`: post-install activation depends on reading the caller's `$HOME`. +- In CI/Docker where a transient activation failure must not fail the install, pass `--no-connect` to keep install-only exit semantics. diff --git a/docs/features/install/configuration.md b/docs/features/install/configuration.md new file mode 100644 index 00000000..3b7e191c --- /dev/null +++ b/docs/features/install/configuration.md @@ -0,0 +1,17 @@ +# Install — Configuration + +The install domain reads one optional section of `.goga/config.yml` — `tools`, the version declarations consumed by [`goga install`](cli.md) in bulk mode (a bare `goga install` with no name and no `--local`). + +```yaml +tools: + viewer: latest + mkdocs: 1.0.x +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `tools` | mapping | No | goga-tool version declarations. Keys are tool names (without the `goga-tool-` prefix); values are version-form strings. Values are stored verbatim — the four-form grammar (`1.0.x`, `1.x`, `1.0.1`, `latest`) is validated by `goga install`, not the loader. Defaults to `None` (absent); an empty mapping is `{}`. YAML-null values (`viewer:`) are rejected | + +Single and local modes ignore the section entirely — the version comes from `--version` or the local path. + +The general file location, loading rules, and the shared example live in [Project Configuration](../../configuration/project.md). diff --git a/docs/features/install/hooks.md b/docs/features/install/hooks.md new file mode 100644 index 00000000..7b2ac110 --- /dev/null +++ b/docs/features/install/hooks.md @@ -0,0 +1,14 @@ +# Install — Hooks + +The install domain exposes **no hook actions** of its own — it invokes a tool-package **lifecycle callback** instead. + +## The post-install hook + +A tool package **may** expose an `install(user: str | None = None)` callable in its facade. `goga install` calls it after a successful pip, passing the initiating user (`SUDO_USER` when goga itself runs under sudo, else the current OS user) only when the parameter is declared keyword-capable; otherwise the hook is called with no arguments. + +- A missing or non-callable `install` is skipped quietly. +- A failing hook exits 1 — the tool name and hook message go to stderr, the pip package stays, activation does not run, and a bulk install stops at the first failing hook. +- The hook still runs under `--no-connect` (the flag skips activation only). +- In local mode, the `:` suffix of `--local` names the tool whose hook runs; without it no hook runs (a warning is logged). + +The invocation surface is covered in [CLI — post-install hooks](cli.md#post-install-hooks). The domain hook actions (a tool's `register_hooks` subscriptions) are the [Hooks](../hooks/hooks.md) platform — fired at domain checkpoints, not at install time. diff --git a/docs/features/install/index.md b/docs/features/install/index.md new file mode 100644 index 00000000..6ac5aa16 --- /dev/null +++ b/docs/features/install/index.md @@ -0,0 +1,20 @@ +# Install + +Install and remove `goga-tool` packages — into the **exact interpreter that runs goga**. + +The install domain manages the tool packages of the running environment. Which tasks it solves: + +- **Install a tool** — `goga install ` pip-installs the package into the running interpreter's pip, regardless of how goga was deployed (pipx venv, system Python, anything else); `--version` pins through the four-form grammar (`1.0.x`, `1.x`, `1.0.1`, `latest`). +- **Install from source** — `goga install --local [:]` pip-installs a local directory without a PyPI lookup. +- **Install the declared set** — a bare `goga install` installs every tool declared under `tools:` in `.goga/config.yml` in one pip call (see [Configuration](configuration.md)). +- **Post-install hooks** — after a successful pip, each freshly installed tool's optional `install(user)` facade callable runs (see [Hooks](hooks.md)). +- **Activate** — every agent recorded in `~/.goga/connect.yml` is re-synced, so the new tool's skills and pipelines appear immediately (`--no-connect` opts out — the install-only form for CI/Docker). +- **Remove a tool** — `goga uninstall ` removes exactly one package after a confirmation, then re-syncs the agents; a tool removed by hand with plain pip leaves its artifacts behind until the next re-sync. + +## In this directory + +- [CLI](cli.md) — the full `goga install` command reference +- [Uninstall](uninstall.md) — the full `goga uninstall` command reference +- [Configuration](configuration.md) — the `tools:` section of `.goga/config.yml` +- [Hooks](hooks.md) — the post-install hook and hook points for tool packages +- [API](api.md) — the `goga.commands.install` package facade diff --git a/docs/cli/uninstall.md b/docs/features/install/uninstall.md similarity index 90% rename from docs/cli/uninstall.md rename to docs/features/install/uninstall.md index b183261d..740fff69 100644 --- a/docs/cli/uninstall.md +++ b/docs/features/install/uninstall.md @@ -14,7 +14,7 @@ goga uninstall [--yes/-y] [--sudo] [--user NAME] Exactly one tool is removed per invocation — no bulk, empty, or local-path forms. The tool `name` is not validated before pip runs: an unknown package is skipped by pip with a WARNING and exit code 0. -The re-sync is the cleanup mechanism. Because tool skills and pipelines are installed centrally into `~/.goga/` and symlinked into each agent directory (see [`goga connect`](connect.md)), removing the package alone would leave orphaned artifacts behind. The post-removal re-sync recreates `~/.goga/skills/` and `~/.goga/pipelines/` from the packages that remain and rebuilds agent symlinks only for entries that still exist — the removed tool's skills and pipelines disappear from `~/.goga/` and from each agent's symlink tree. +The re-sync is the cleanup mechanism. Because tool skills and pipelines are installed centrally into `~/.goga/` and symlinked into each agent directory (see [`goga connect`](../connect/cli.md)), removing the package alone would leave orphaned artifacts behind. The post-removal re-sync recreates `~/.goga/skills/` and `~/.goga/pipelines/` from the packages that remain and rebuilds agent symlinks only for entries that still exist — the removed tool's skills and pipelines disappear from `~/.goga/` and from each agent's symlink tree. ## Confirmation @@ -105,5 +105,5 @@ goga uninstall foo --sudo --user alice ## Notes - `--sudo` and `--user` rely on `sudo` and `pwd.getpwnam` respectively and are unavailable on Windows — omit them there. -- `goga uninstall` never reads or writes `connect.yml` itself — [`goga connect`](connect.md) is the single writer of the registry, reached only through the shared re-sync routine. +- `goga uninstall` never reads or writes `connect.yml` itself — [`goga connect`](../connect/cli.md) is the single writer of the registry, reached only through the shared re-sync routine. - Removing a package by hand with plain pip leaves stale skills and pipelines in `~/.goga/` until the next re-sync runs; prefer `goga uninstall`. diff --git a/docs/features/lint/api.md b/docs/features/lint/api.md new file mode 100644 index 00000000..a6db7be6 --- /dev/null +++ b/docs/features/lint/api.md @@ -0,0 +1,22 @@ +# Lint — API + +The facade of the domain package **`goga.ast`** — the parse-and-validate surface behind `goga lint`. + +The signature below is the CODEMANIFEST contract of the cell. + +```python +AST(path: str, ignore: list[str] | None = None) +``` + +Load the project tree rooted at `path` and validate it: parse every `CODEMANIFEST`, apply the document-level rules to each document, then the tree-level rules across the import graph. `ignore` — directory relative paths pruned from traversal (the `lint.ignore` configuration). The violations surface as the AST error types — `DocumentParseError` for structural YAML/DSL failures, `DocumentRuleError` for document-level rule violations, `ASTRuleError` for tree-level ones (see [Architecture — Error Handling](../../architecture/ast-errors.md)). + +The parse product is the same tree the [Contract](../contract/api.md) and [Schema](../schema/api.md) domains consume, and the one injected into a tool's keyword-capable `ast` parameter (see [Tools — CLI](../tools/cli.md#optional-injections)). + +## Example + +```python +from goga.ast import AST + +tree = AST(path=".", ignore=[".venv"]) +print(tree) # the validated document tree +``` diff --git a/docs/cli/lint.md b/docs/features/lint/cli.md similarity index 96% rename from docs/cli/lint.md rename to docs/features/lint/cli.md index 71474863..49a1de46 100644 --- a/docs/cli/lint.md +++ b/docs/features/lint/cli.md @@ -25,7 +25,7 @@ lint: - build/dist ``` -A directory is pruned when its exact normalized relative path matches an `ignore` entry. Matching is literal — glob patterns are **not** interpreted, and a trailing separator is insignificant (`.venv/` and `.venv` are equivalent). Only full relative paths match: `ignore: [.venv]` prunes a top-level `.venv` but not a nested `a/b/.venv`. The `lint` section is optional; when it is absent or the config cannot be loaded, lint behavior is unchanged (every directory is linted). See [Configuration](../configuration/index.md#lint). +A directory is pruned when its exact normalized relative path matches an `ignore` entry. Matching is literal — glob patterns are **not** interpreted, and a trailing separator is insignificant (`.venv/` and `.venv` are equivalent). Only full relative paths match: `ignore: [.venv]` prunes a top-level `.venv` but not a nested `a/b/.venv`. The `lint` section is optional; when it is absent or the config cannot be loaded, lint behavior is unchanged (every directory is linted). See [Configuration](configuration.md). ## Arguments diff --git a/docs/features/lint/configuration.md b/docs/features/lint/configuration.md new file mode 100644 index 00000000..7aa49eac --- /dev/null +++ b/docs/features/lint/configuration.md @@ -0,0 +1,18 @@ +# Lint — Configuration + +The lint domain reads one optional section of `.goga/config.yml` — `lint`, consumed by [`goga lint`](cli.md). + +```yaml +lint: + ignore: + - .venv/ + - build/dist +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `lint.ignore` | list of strings | No | Directory relative paths to skip during lint traversal, stored verbatim. A directory matches when its exact normalized relative path equals an entry; glob patterns are not interpreted and a trailing separator is insignificant. Defaults to `[]` when `lint` is present but `ignore` is absent | + +When the section is absent (or the config cannot be loaded), lint behavior is unchanged — every directory is linted. Structural type errors (a non-mapping `lint`, a non-list `lint.ignore`, or a non-string element) raise `ValueError` at load time. + +The general file location, loading rules, and the shared example live in [Project Configuration](../../configuration/project.md). diff --git a/docs/features/lint/errors.md b/docs/features/lint/errors.md new file mode 100644 index 00000000..797ffdee --- /dev/null +++ b/docs/features/lint/errors.md @@ -0,0 +1,93 @@ +# Lint — Errors + +The catalog of validation errors [`goga lint`](cli.md) reports — one entry per rule. 24 rules in two scopes: **document-level** (21, applied to each CODEMANIFEST by the AST visitor) and **tree-level** (3, applied across the import graph by the analyzer). + +## Reading an error + +``` +[RULE_NAME] Error message + --> path/to/CODEMANIFEST + --- + yaml_fragment_key: value + ... +``` + +The rule name in brackets, the message, the offending document, and the YAML fragment that triggered it. A closing summary counts the run: + +``` +goga lint +------------------------- +cells: N errors: M +``` + +Structural failures that are not rule violations surface as parse errors: a document that is not valid YAML or violates the document shape (`DocumentParseError`) — see [Architecture — Error Handling](../../architecture/ast-errors.md). + +## Import errors (8) + +The `Imports` section of the header. + +| Rule | Scope | The error means | +|---|---|---| +| `ImportsCanNotBeEmpty` | Document | The `Imports` block is declared but empty — no Types and no Usages listed (a document without an `Imports` block is not flagged) | +| `ImportsHasOnlyValidKeys` | Document | An import item carries a key other than `Types`, `Usages`, `From` | +| `ImportItemIsValid` | Document | An import item lists no Types or no Usages | +| `ImportHasNotDuplicate` | Document | The same import entry appears twice in the list | +| `ImportHasValidFromPath` | Document | The `From` path is not a valid source path (escapes the project, absolute, malformed) | +| `ImportUsageExists` | Document | A usage file referenced in imports does not exist at `{From}/.usages/.md` | +| `ImportIsUsed` | Document | A declared import is never referenced in the document body | +| `ImportTypeExists` | Tree | An imported type exists nowhere in the project tree | + +## Usage errors (4) + +The `Usages` section of the header. + +| Rule | Scope | The error means | +|---|---|---| +| `AllUsagesIsUsed` | Document | A declared usage is never referenced in any annotation | +| `UsageFilepathExists` | Document | A usage declared by file path does not exist on disk (project-level practices must reside in `.goga/usages/`) | +| `UsageUrlIsAccessible` | Document | A usage declared by URL is not reachable (duplicate URLs are checked once per run) | +| `UsageLinksHasNotConflicts` | Document | Two usage links resolve to the same name — an import collides with a local `Usages` key | + +## Structure errors (6) + +The body — entities, routines, signatures, locations. + +| Rule | Scope | The error means | +|---|---|---| +| `EntitiesAndRoutinesHasNotConflicts` | Document | An entity or routine has the same name as an imported name — use an alias in Imports | +| `EntityHasOnlyValidKeys` | Document | An entity declaration carries a key other than `location`, `annotations`, `methods`, `properties` | +| `RoutineHasOnlyValidKeys` | Document | A routine declaration carries a key other than `location`, `annotations` | +| `SignatureIsValid` | Document | A type signature does not follow the expected format | +| `LocationIsRequired` | Document | An entity or routine has no `location`, or its `location` carries a directory path or lacks a file extension | +| `ReturnTypeHasLink` | Document | A return type in a signature has no paired semantic label (`-> value:Type`, not `-> Type`) | + +## Mutation errors (3) + +Mutation declarations on entities. + +| Rule | Scope | The error means | +|---|---|---| +| `MutationExists` | Document | The base type of a mutation does not exist | +| `MutationIsValid` | Document | The mutation declaration is malformed | +| `EmbeddedEntityCanNotHasMutations` | Document | An embedded entity (`->Type: {}`) declares mutations | + +## Annotation errors (1) + +| Rule | Scope | The error means | +|---|---|---| +| `AnnotationLinksExists` | Document | A backtick reference in an annotation points to no entity of the document context — a signature variable, a type, or a practice that does not resolve | + +## Tree-level errors (3) + +Rules that need the cross-document context. + +| Rule | The error means | +|---|---| +| `ImportsHasNotCyclicalDeps` | A circular import chain exists between CODEMANIFEST documents (cell A imports from B while B imports from A) | +| `ImportTypeExists` | An imported type cannot be found anywhere in the full document tree | +| `EmbeddedTypeHasLowLevel` | An embedded entity does not follow the correct hierarchy level relative to its parent | + +## Where to next + +- [Validation Rules Reference](../../architecture/validation-rules.md) — the same rules from the implementation side. +- [AST Visitor](../../architecture/ast-visitor.md) / [AST Analyzer](../../architecture/ast-analyzer.md) — how document-level and tree-level rules are applied. diff --git a/docs/features/lint/hooks.md b/docs/features/lint/hooks.md new file mode 100644 index 00000000..5e6cdab8 --- /dev/null +++ b/docs/features/lint/hooks.md @@ -0,0 +1,5 @@ +# Lint — Hooks + +The lint domain exposes **no hook actions** for tool packages today. + +The rule set is fixed by the goga AST; a project extends validation through its conventions (`.goga/usages/`) rather than through hooks. The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/lint/index.md b/docs/features/lint/index.md new file mode 100644 index 00000000..62ca6c1d --- /dev/null +++ b/docs/features/lint/index.md @@ -0,0 +1,20 @@ +# Lint + +Validate CODEMANIFEST files in a project. + +The lint domain is the structural gate of the DSL. Which tasks it solves: + +- **Validate the manifest** — `goga lint` parses every `CODEMANIFEST` in the project tree and checks it against the rule set: 21 document-level rules (applied per document by the AST visitor) and 3 tree-level rules (applied across the import graph by the analyzer). +- **Report precisely** — every error carries the rule name, the message, the document path, and the offending YAML fragment; a closing summary counts cells and errors. +- **Scope the noise** — the `lint.ignore` list prunes directories (a vendored `.venv`, a build output) from traversal before validation. +- **Explain the failures** — the [error catalog](errors.md) describes what each rule checks and what a violation means. + +What each rule *means* semantically — the DSL itself — is covered in [Cell](../../cell/index.md); how the rules are implemented (the visitor, the analyzer, the error hierarchy) in [Architecture](../../architecture/index.md). + +## In this directory + +- [CLI](cli.md) — the full `goga lint` command reference +- [Configuration](configuration.md) — the `lint:` section of `.goga/config.yml` +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.ast` package facade +- [Errors](errors.md) — the catalog of validation errors diff --git a/docs/features/pipelines/api.md b/docs/features/pipelines/api.md new file mode 100644 index 00000000..03e69abb --- /dev/null +++ b/docs/features/pipelines/api.md @@ -0,0 +1,63 @@ +# Pipelines — API + +The facade of the domain package **`goga.pipeline`** — discovery and run coordination of goga pipeline files. The DSL parsing and flow compilation live in the nested cells `goga.pipeline.workflow` and `goga.pipeline.compiler`; this facade carries the discovery, description, and run surfaces. + +The signatures below are the CODEMANIFEST contract of the cell. + +## Discovery and description + +```python +list_pipelines(project_dir: Path, user_dir: Path) -> list[PipelineEntry] +describe_pipelines(project_dir: Path, user_dir: Path) -> list[PipelineSummary] +describe_pipeline(name: str, project_dir: Path, user_dir: Path, + workflow: str | None, no_workflow: bool) -> PipelineCard +``` + +`list_pipelines` enumerates the flat `*.yml` files of the two sources (project wins on name conflict); `describe_pipelines` adds each pipeline's header fields; `describe_pipeline` compiles the pipeline with the same workflow rule set as a run and returns its card — the stages in execution order. + +```python +PipelineEntry(name: str, source: PipelineSource) +PipelineSummary(name: str, source: PipelineSource, description: str, display_name: str = "") +PipelineCard(name: str, description: str, stages: list[CardStage]) +CardStage(id: str, title: str) +``` + +The discovery and description result types. `PipelineSource` distinguishes the project and user origins. + +## Workflow resolution and stage ordering + +```python +resolve_workflow(pipeline_name: str, workflow_name: str | None, + no_workflow: bool) -> WorkflowDocument | None +apply_skip_stages(workflow: WorkflowDocument | None, skip_stages: list[str]) -> WorkflowDocument | None +order_stages(stages: list[FlowStage]) -> list[FlowStage] +``` + +`resolve_workflow` applies the three invocation modes — auto-match, explicit `--workflow`, `--no-workflow`. `apply_skip_stages` removes skipped stages and reconnects their dependents. `order_stages` topologically orders the compiled stages for execution. + +## Execution + +```python +run_pipeline(name: str, project_dir: Path, user_dir: Path, port: int, + parallel: int | None = None) -> int +pipeline_cli(argv: list[str]) -> int +``` + +`run_pipeline` is the in-container execution: compile the pipeline-file into a flow-file, materialize the agent prompts, and run it via `afm` — the container exit code is returned. `parallel` caps the stages afm executes concurrently (`None` — unbounded). `pipeline_cli` is the in-container argparse entry point behind `goga pipeline` (the host-side launcher is the [Install/CLI layer](cli.md)). + +## Example + +```python +from pathlib import Path +from goga.pipeline import describe_pipeline, list_pipelines + +project = Path(".goga/pipelines") +user = Path.home() / ".goga/pipelines" + +for entry in list_pipelines(project, user): + print(entry.name, entry.source) + +card = describe_pipeline("development", project, user, None, False) +for stage in card.stages: + print(stage.id, "—", stage.title) +``` diff --git a/docs/cli/pipeline.md b/docs/features/pipelines/cli.md similarity index 73% rename from docs/cli/pipeline.md rename to docs/features/pipelines/cli.md index 56a0cdae..e4be5238 100644 --- a/docs/cli/pipeline.md +++ b/docs/features/pipelines/cli.md @@ -11,6 +11,8 @@ goga pipeline --list # flat list: available pipeline names (in-cont goga pipeline --list --info # overview: one bullet block per pipeline with its description goga pipeline --info # card: name, description, stages in execution order goga pipeline # run: execute the pipeline (in-container) +goga pipeline -t # run: first switch onto the branch hosting the work (host-side) +goga pipeline -t --todo # same, then open the topic's todo.md in the editor ``` ## Forms @@ -58,14 +60,14 @@ The card and the run share the same workflow rule set and the same compiler, so ## Run Mode (`goga pipeline `) -Run a pipeline by name. Pass the bare name only (no `.yml` extension); the container resolves the absolute path internally, compiles the goga DSL pipeline-file into an afm flow-file at `/flow.yml`, materializes the four agent prompt files into `/prompts/` (applying any `roles` overrides from the pipeline-file header — see [Custom agent prompts](#custom-agent-prompts)), and runs that via `afm run`. Passing `-p/--parallel N` caps the number of stages afm executes concurrently (it threads through to `afm run --max-parallel `); without it afm runs unbounded. A free port is allocated automatically and published on both sides (`-p :`); `afm` listens on that port inside the container. When a workflow is applied, a single log line naming it is printed to stdout; otherwise the launcher prints no status line. +Run a pipeline by name. Pass the bare name only (no `.yml` extension); the container resolves the absolute path internally, compiles the goga DSL pipeline-file into an afm flow-file at `/flow.yml`, materializes the four agent prompt files into `/prompts/` (applying any `roles` overrides from the pipeline-file header — see [Custom agent prompts](#custom-agent-prompts)), and runs that via `afm run`. Passing `-p/--parallel N` caps the number of stages afm executes concurrently (it threads through to `afm run --max-parallel `); without it afm runs unbounded. A free port is allocated automatically and published on both sides (`-p :`); `afm` listens on that port inside the container. When a workflow is applied, a single log line naming it is printed to stdout; when `-t/--topic` brought the repository onto the requested work, the single result line of the topic procedure (`Switched to branch `, `Created branch from /`, `Already on branch `, or `Created branch and topic /`) is echoed once before the launch; otherwise the launcher prints no status line. Pipelines are flat `*.yml` files (one per pipeline) resolved from two directories, with the project source winning on name conflicts: | Source | Directory | Origin | |---------|--------------------------|------------------------------------------------------------| | project | `/.goga/pipelines/` | Checked into / authored for the current project | -| user | `~/.goga/pipelines/` | Installed centrally by `goga connect` (see [connect](connect.md)) | +| user | `~/.goga/pipelines/` | Installed centrally by `goga connect` (see [connect](../connect/cli.md)) | Only top-level `*.yml` is scanned — subdirectories are ignored, and `.yaml` files are excluded. Pipeline path resolution and discovery happen **inside** the container (the host does not resolve pipeline paths). @@ -87,6 +89,36 @@ Pipeline running with workflow "feature-phases" If the name exists in both sources, the project source wins. The container exit code is propagated as the command's exit code. +### Topic switch + +The run form can first bring the repository onto the requested work — continuing existing work, or creating fresh work in one command when nothing hosts the identifier: + +```bash +goga pipeline development -t feat/x +goga pipeline refinement -t prune-history-and-new-status +``` + +The identifier resolves through three tiers, and the first tier with a match wins (so a non-interactive `-t` never reaches a prompt): + +1. **exact branch name** — a branch whose display name equals the input; +2. **exact topic slug** — a branch hosting the topic `.goga/history///` whose slug equals the normalized input (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`); local branches come before remote-tracking refs; +3. **prefix** — a branch whose name, or whose hosted slug, starts with the input. + +Within a tier, several candidates may match (a branch chain carries several topics). On an interactive terminal goga prints the numbered list with each candidate's statuses and prompts for a number; with no terminal (CI/scripts) the numbered list itself becomes a clean error and the command exits 1 — no image refresh, build, or launch happens. No candidate at all creates fresh work instead of failing: the branch is created with the name as entered off the current HEAD, the repository switches to it, and the topic directory of the year is created from its slug (`Created branch and topic /`). An unusable name — one that normalizes to an empty slug, or one already occupied by an existing branch, a remote-tracking twin, or the topic directory of the year — is one clean error (exit 1) with the reason; there is no re-ask. + +The outcome: + +- already on the hosting branch → idempotent success, nothing is touched and the working tree is not even probed; +- a local host → `git switch `; +- a remote-only host → the local branch is created from the remote-tracking ref (`git switch -c /`); +- nothing hosts the identifier → the branch is created as entered from the current HEAD and the topic directory of the year appears (uncommitted changes carry onto the fresh branch; `goga topics create` instead plants the branch at an explicit or configured base and, by default, leaves you on your branch). + +A switch that would mutate checks the working tree first: a dirty tree exits 1 with `working tree is dirty — commit or stash before switching` before anything is touched. Every git action happens on the host, after every form check and before any docker activity. The single result line (`Switched to branch `, `Created branch from /`, `Already on branch `, or `Created branch and topic /`) is echoed to stdout once, before the launch. The branch name is never forwarded into the container — the container sees the branch through the mounted project, and goga does not switch back after the launch. + +With `--todo`, the topic procedure opens the external editor with the ensured work's `todo.md` after the switch or the fresh creation — saving overwrites the file (no commit), cancelling leaves it untouched, as in `goga topics switch --todo` — but unlike switch, a hosting branch without a topic gets its topic directory created and the todo entered. The flag needs an interactive terminal and acts only together with `--topic`: `--todo` without `--topic` in the run form is a clean error (`--todo acts only together with --topic`, exit 1) fired before any git or docker activity. + +The flat list, overview, and card forms silently ignore `-t` and `--todo` — passing them there is not an error and has no effect. + ## Prerequisites All forms launch a Docker container via the host **`docker`** CLI: @@ -133,9 +165,9 @@ Three invocation modes (mutually exclusive in the explicit cases), honored by bo For a run, the decision reaches the container via the env-file (`GOGA_WORKFLOW_NAME=` for `--workflow`; `GOGA_WORKFLOW_DISABLED=1` for `--no-workflow`; neither for auto-match). For a card (` --info`), the same flags travel in the `docker run` argv — the composition the card prints is exactly the composition a run with the same flags executes. -When a workflow will actually be applied to a run (explicit `--workflow`, or an auto-match file that exists), the launcher prints `Pipeline running with workflow ""` to stdout. When no workflow applies, the launcher prints no workflow line. The launcher surfaces only the workflow log line, the `docker` output stream, and any pre-launch version-check warning or refusal on stderr (see [Pre-launch version check](#pre-launch-version-check)). +When a workflow will actually be applied to a run (explicit `--workflow`, or an auto-match file that exists), the launcher prints `Pipeline running with workflow ""` to stdout. When no workflow applies, the launcher prints no workflow line. The launcher surfaces only the workflow log line, the `docker` output stream, any pre-launch version-check warning or refusal on stderr (see [Pre-launch version check](#pre-launch-version-check)), and, in the run form with `-t`, the single topic result line. -Inside the container the goga in-container process resolves and parses the workflow-file, then forwards it to the compiler, which reconstructs the parsed body: `extend` entries inject new stages positioned via `before`/`after`, per-stage `agent` overrides compose the in-container wrapper path into the stage's `command` slot, per-stage `prompt` overrides fill its `description` slot, `skip: true` removes the stage and reconnects its dependents' `depends_on`, a `loop: N` (N ≥ 2) expands the stage into `NAME-1`..`NAME-N` copies with chained internal `depends_on` (external references are rewritten to the LAST expanded id), and `manual: true|false` forces or cancels the stage's manual launch mode (compiling to the afm `auto_run` key). +Inside the container the goga in-container process resolves and parses the workflow-file, then forwards it to the compiler, which reconstructs the parsed body: `extend` entries inject new stages positioned via `before`/`after`, per-stage `agent` overrides compose the in-container wrapper path into the stage's `command` slot, per-stage `prompt` overrides fill its `description` slot, `skip: true` removes the stage and reconnects its dependents' `depends_on`, a `loop: N` (N ≥ 2) expands the stage into `NAME-1`..`NAME-N` copies with chained internal `depends_on` (external references are rewritten to the LAST expanded id), `manual: true|false` forces or cancels the stage's manual launch mode (compiling to the afm `auto_run` key), and a `memory` block with per-stage `reflect` / `memory` instructions emits the afm top-level `memory` block and the per-stage `reflect` / `memory_use` keys (only when at least one stage participates — see [Workflows — Project memory](workflows.md#project-memory-memory-reflect)). Example workflow-file: @@ -159,11 +191,13 @@ stages: | `name` (positional) | string | — | Pipeline name without extension. Selects the card (`--info`) or run form; omit it and pass `--list` for the listing forms. `--list` and a name together are rejected (exit 1) | | `-l`, `--list` | flag | off | List available pipelines (flat list). Add `--info` for a one-line description per pipeline | | `-i`, `--info` | flag | off | With `--list`: print the overview. With `NAME`: print the pipeline card instead of running it | +| `-t`, `--topic` | string | — | Bring the repository onto the requested work before the run — a branch name, a topic slug, or their prefix, created fresh when nothing hosts it; see [Topic switch](#topic-switch). Run form only — the list/info forms silently ignore it | +| `--todo` | flag | off | Open the editor with the ensured work's `todo.md` after the switch or the fresh creation (run form only; requires `--topic` and an interactive terminal; the info forms silently ignore it; `--todo` without `--topic` is a clean error) | | `-e`, `--env` | string (repeatable) | — | Additional environment variable (`KEY=VALUE`) forwarded into the container env-file. Run form only | | `--proxy` | string | config | HTTP/HTTPS proxy URL; overrides `pipeline.proxy`. Adds `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` to the container env-file. Run form only | | `--add-host` | string (repeatable) | -- | Add a `docker run --add-host HOST:IP` entry; merges on top of `pipeline.hosts` (CLI wins on key conflict). Run form only — the info forms receive the configured `pipeline.hosts` only | | `-c`, `--clean` | flag | off | Wipe the persistent afm state directory before launch. Run form only | -| `--update`, `-u` | flag | off | Refresh the image before launch (build if a project Dockerfile is declared, else pull). Effective in the run and flat-list forms; a deliberate no-op in the `--info` forms | +| `-u`, `--update` | flag | off | Refresh the image before launch (build if a project Dockerfile is declared, else pull). Effective in the run and flat-list forms; a deliberate no-op in the `--info` forms | | `-w`, `--workflow` | string | — | Apply an explicit workflow at `/.goga/workflows/.yml`. The file must exist on the host (exit 1 if missing). Mutually exclusive with `--no-workflow`. Honored by the run and card forms | | `--no-workflow` | flag | off | Disable workflow application entirely (a run writes `GOGA_WORKFLOW_DISABLED=1` into the container env-file). Mutually exclusive with `--workflow`. Honored by the run and card forms | | `-s`, `--skip` | string (repeatable) | — | Exclude a stage from the compiled pipeline (one name per invocation). The stage is removed and its dependents' `depends_on` are reconnected. Forwarded into the container env-file as `GOGA_SKIP_STAGES=,...`. Not mutually exclusive with `--workflow`/`--no-workflow`. Run form only; the host performs no name validation — unknown names surface in-container as a structural error. The card does not read it (the card answers "what is this pipeline?", not "what would this particular run skip?") | @@ -177,6 +211,8 @@ Run mode mounts a host directory at `/home/goga/pipeline` inside the container a ~/.goga/runtime/pipelines//// ``` +A `:` in the pipeline name becomes `-` in the path segment (`acme:deploy` → `acme-deploy`). + It is created before launch and is **not** deleted on exit. Use `--clean` to wipe it before launch when you want a fresh run. Note that the `prompts/` subdirectory inside it is regenerated on every run (wiped and rebuilt from the shipped defaults plus any `roles` overrides) — it does not persist user-placed content even though the parent directory survives across runs. @@ -234,6 +270,12 @@ Wipe persistent afm state for this pipeline/branch before launch: goga pipeline deploy --clean ``` +Start the run on the branch hosting an existing piece of work: + +```bash +goga pipeline development -t feat/x +``` + ## Exit Codes Host side (all forms): @@ -241,8 +283,8 @@ Host side (all forms): | Code | Meaning | |------|---------| | `0` | The operation completed (container exit 0) | -| `1` | A `ClickException`: a form error (bare invocation, `--list` + name, `--workflow` + `--no-workflow`), the `pipeline` section missing in `.goga/config.yml`, an explicit `--workflow ` naming a file that does not exist or escaping the workflows dir, or a fatal image build/refresh. Or the pre-launch version check refusing the launch (a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version — a stderr message plus `SystemExit`, see [Pre-launch version check](#pre-launch-version-check)) | -| other| The container's exit code, propagated unchanged (including the run-mode codes below) | +| `1` | A `ClickException`: a form error (bare invocation, `--list` + name, `--workflow` + `--no-workflow`, `--todo` without `--topic` in the run form), the `pipeline` section missing in `.goga/config.yml`, an explicit `--workflow ` naming a file that does not exist or escaping the workflows dir, a topic-procedure failure (several candidates without a terminal, a dirty working tree on a switch, an unusable — empty-slug or occupied — name, `--todo` without a terminal, a failed `git switch` or ref listing, or a missing git binary — see [Topic switch](#topic-switch)), or a fatal image build/refresh. Or the pre-launch version check refusing the launch (a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version — a stderr message plus `SystemExit`, see [Pre-launch version check](#pre-launch-version-check)) | +| other | The container's exit code, propagated unchanged (including the run-mode codes below) | Container side, run form: diff --git a/docs/features/pipelines/configuration.md b/docs/features/pipelines/configuration.md new file mode 100644 index 00000000..9d66ed82 --- /dev/null +++ b/docs/features/pipelines/configuration.md @@ -0,0 +1,22 @@ +# Pipelines — Configuration + +The pipelines domain reads one optional section of `.goga/config.yml` — `pipeline`, the afm execution settings. The section must be present for the run form: `goga pipeline ` exits with a `ClickException` naming `pipeline` when it is absent (the list/info forms do not read it). + +```yaml +pipeline: + agent: claude # the agent that runs the stages inside the container + env: {} # environment variables of the pipeline container + proxy: http://corp:3128 + hosts: {foo.local: "127.0.0.1"} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `pipeline.agent` | `string` | No | AI agent that runs the pipeline stages inside the container. Optional at the loader level — absent/YAML-null/empty/whitespace resolves to `None`. When `None`, the agent may be supplied by a per-stage workflow override or afm's own default, so `goga pipeline` does not require it. Same resolution mechanic and baseline set as `build.task_executor.agent` — see [Agents](../../configuration/agents.md) | +| `pipeline.env` | mapping | No | Environment variables passed into the pipeline container. Keys and values must be strings. Defaults to `{}` | +| `pipeline.proxy` | `string` | No | HTTP/HTTPS proxy URL for the pipeline container. When set, `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` are written to the container env-file. Overridden by the `--proxy` CLI option | +| `pipeline.hosts` | mapping | No | Host→IP mapping for `docker run --add-host`. Defaults to `{}`. Augmented by the repeatable `--add-host` CLI option (CLI wins on key conflict) | + +Two adjacent settings the domain consumes live at the top level of the file, not inside the section: `image` — the container image both `goga build` and `goga pipeline` launch — and `dockerfile` — the project Dockerfile `goga pipeline --update` builds from when set (see [Project Configuration](../../configuration/project.md)). + +The general file location, loading rules, and the shared example live in [Project Configuration](../../configuration/project.md). diff --git a/docs/features/pipelines/hooks.md b/docs/features/pipelines/hooks.md new file mode 100644 index 00000000..639980b8 --- /dev/null +++ b/docs/features/pipelines/hooks.md @@ -0,0 +1,5 @@ +# Pipelines — Hooks + +The pipelines domain exposes **no hook actions** for tool packages today. + +A tool package reaches the pipeline surface not through hooks but through its own artifacts: its skills merge into pipeline stages via the workflow `skills:` mechanism, and its pipeline-files install namespaced as `:.yml` and run as `goga pipeline :` (see [Tools](../tools/index.md)). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/pipelines/index.md b/docs/features/pipelines/index.md similarity index 80% rename from docs/pipelines/index.md rename to docs/features/pipelines/index.md index d88507e5..d814657e 100644 --- a/docs/pipelines/index.md +++ b/docs/features/pipelines/index.md @@ -20,10 +20,10 @@ Pipelines ship six ready-to-use definitions: The shipped pipelines are described in detail in [Shipped Pipelines](shipped.md). -This section documents the **functional model** of pipelines — what a +This page documents the **functional model** of pipelines — what a pipeline-file is, what a workflow is, and how the two relate. For invocation flags, exit codes, and Docker mechanics, see the -[`goga pipeline` CLI reference](../cli/pipeline.md). +[`goga pipeline` CLI reference](cli.md). ## Pipeline files vs workflows @@ -37,7 +37,9 @@ The pipelines layer is split into two authoring surfaces: - **[Workflows](workflows.md)** — an optional layering document that extends a compiled pipeline at run time with a top-level prompt, per-stage agent / prompt overrides, loop expansion, stage skipping via `skip`, manual launch - via `manual`, and new stages declared via `extend`. + via `manual`, note buttons via `notes` (compiled to the afm `buttons` + field), new stages declared via `extend`, and project-memory participation + via the `memory` block and the per-stage `reflect` / `memory` instructions. Authored per project; lives in `.goga/workflows/.yml` (project-only). A pipeline-file answers **what** the pipeline does. A workflow answers @@ -83,7 +85,11 @@ the afm auto-approval effects (interactive suppression and/or `auto_approve` emission), and per-stage `manual: true|false` forces or cancels the stage's manual launch mode (a stage-body `trigger: manual` compiles to the afm `auto_run: false` key — the stage pauses until -launched). The pipeline-file itself can also carry +launched). A per-stage `notes` map compiles verbatim to the afm `buttons` +field (note buttons). A workflow `memory` block plus per-stage `reflect` / +`memory` instructions compile to the afm top-level `memory` block and the +per-stage `reflect` / `memory_use` keys — emitted only when at least one +stage participates. The pipeline-file itself can also carry `before_script` / `script` / `after_script` shell directives on any stage — compiled to the afm `script_*` keys — and a `timeout` directive (Go duration string) that compiles to the afm `script_timeout` key and bounds @@ -98,4 +104,14 @@ for the full semantics. - Layer project-specific behavior on top — read the [Workflows](workflows.md) reference. - Run a pipeline from the command line — see the - [`goga pipeline` CLI reference](../cli/pipeline.md). + [`goga pipeline` CLI reference](cli.md). + +## In this directory + +- [CLI](cli.md) — the full `goga pipeline` command reference +- [Configuration](configuration.md) — the `pipeline:` section of `.goga/config.yml` +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.pipeline` package facade +- [Pipeline File](pipeline-file.md) — the base authoring document +- [Workflows](workflows.md) — the project-specific layering document +- [Shipped Pipelines](shipped.md) — the six ready-to-use definitions diff --git a/docs/pipelines/pipeline-file.md b/docs/features/pipelines/pipeline-file.md similarity index 95% rename from docs/pipelines/pipeline-file.md rename to docs/features/pipelines/pipeline-file.md index 727ada19..e3aebdab 100644 --- a/docs/pipelines/pipeline-file.md +++ b/docs/features/pipelines/pipeline-file.md @@ -129,6 +129,17 @@ assigned semantics: > (the stage pauses when reached and runs only when launched manually). Authoring > `auto_run` directly is rejected with a structural error — use > `trigger: manual`. +> +> The per-stage `buttons` field (note buttons) has no pipeline-file authoring +> key at all: it is assembled by the compiler from a workflow `notes` +> instruction (see [Workflows — Note buttons](workflows.md#note-buttons-notes)). +> Authoring `buttons` in a stage body is rejected with a structural error. +> +> Symmetrically, the memory keys `reflect` and `memory_use` have no +> pipeline-file authoring key: they are assembled by the compiler from the +> workflow memory instructions (see +> [Workflows — Project memory](workflows.md#project-memory-memory-reflect)). +> Authoring either in a stage body is rejected with a structural error. | Field | Type | Default | Description | |---------------|------------------|-----------------------------|------------------------------------------------------------------------------| @@ -446,6 +457,9 @@ workflow-agent semantics. | Legacy `agents` key in a stage body | `agents key is forbidden in stage body; use roles` | | Authoring `interactive` in a stage body | `interactive key is forbidden in stage body; use communication` | | Authoring `auto_run` in a stage body | `auto_run key is forbidden in stage body; use trigger: manual` | +| Authoring `buttons` in a stage body | `buttons key is forbidden in stage body; use notes in workflow.stages` | +| Authoring `reflect` in a stage body | `reflect key is forbidden in stage body; use reflect in workflow.stages` | +| Authoring `memory_use` in a stage body | `memory_use key is forbidden in stage body; use memory in workflow.stages` | | `trigger` value outside `on_success`/`manual` | `trigger must be one of: on_success, manual` | | `timeout` value is not a string (including YAML-null) | `timeout must be a string in stage ` | | `timeout` without `script` in the same body | `timeout requires script in stage ` | @@ -457,5 +471,5 @@ workflow-agent semantics. - [Workflows](workflows.md) — layer project-specific overrides on top of a compiled pipeline. -- [`goga pipeline` CLI reference](../cli/pipeline.md) — invocation flags, +- [`goga pipeline` CLI reference](cli.md) — invocation flags, exit codes, and Docker mechanics. diff --git a/docs/pipelines/shipped.md b/docs/features/pipelines/shipped.md similarity index 92% rename from docs/pipelines/shipped.md rename to docs/features/pipelines/shipped.md index 66422fcf..466723a2 100644 --- a/docs/pipelines/shipped.md +++ b/docs/features/pipelines/shipped.md @@ -61,7 +61,7 @@ resolved by the `--force-overwrite` flag passed to `goga connect`: | `true` | The tool's pipeline overwrites the existing file at `:.yml`. | This mirrors the residual-conflict semantics used for tool-skill -installation — see [`goga connect`](../cli/connect.md). +installation — see [`goga connect`](../connect/cli.md). ### Idempotency @@ -79,7 +79,7 @@ Once installed, shipped pipelines behave like any other user pipeline. They are discoverable by `goga pipeline`, can be applied as-is, layered on with a [workflow](workflows.md), or shadowed by a same-named project pipeline (project source wins on name conflicts — see -[Discovery](pipeline-file.md#document-shape)). +[Discovery](index.md#discovery)). ## `refinement` @@ -94,9 +94,11 @@ define → discover → propose → task-review records the settled technical decisions as a short ADR; `propose` formulates the structured task; `task-review` verifies it. The `define`, `discover`, `propose`, and `task-review` stages emit and consume -documents named after the current git branch (`docs/defines/`, -`docs/proposals/`, `docs/tasks/`), and each later stage falls back to -the earlier artifacts when they exist. +documents under the current branch's history topic +(`.goga/history///` — consuming `todo.md` and emitting +`prd.md`, `adr.md`, `task.md`, with `` the kebab-case slug of +the current git branch), and each later +stage falls back to the earlier artifacts when they exist. ## `development` @@ -176,12 +178,12 @@ the user to specify the verification procedure. `commit-changes` commits the accumulated fixes. -All four review stages (`code-review`, `contracts-review`, -`documentation-review`, `testing`) carry shared constraints: do not -fabricate a finding priority when it is not obvious (set it as -`unknown`); do not run lint/format/tests outside the dedicated `testing` +The three finding stages (`code-review`, `contracts-review`, +`documentation-review`) carry shared constraints: do not fabricate a +finding priority when it is not obvious (set it as `unknown`); do not +run the project's lint/format/tests outside the dedicated `testing` stage; fix findings only after user confirmation. The `testing` stage -must not ignore any error. +instead fixes every error — it must not ignore any. ## `sync` @@ -231,6 +233,6 @@ recreates that directory on every run. forking a pipeline. - [Workflows](workflows.md) — layer project-specific behavior on top of a shipped pipeline without forking it. -- [`goga connect` CLI reference](../cli/connect.md) — install shipped +- [`goga connect` CLI reference](../connect/cli.md) — install shipped pipelines into the user pipeline directory, namespacing rules, and residual conflict-resolution semantics. diff --git a/docs/pipelines/workflows.md b/docs/features/pipelines/workflows.md similarity index 73% rename from docs/pipelines/workflows.md rename to docs/features/pipelines/workflows.md index 84629364..981f20a1 100644 --- a/docs/pipelines/workflows.md +++ b/docs/features/pipelines/workflows.md @@ -5,8 +5,11 @@ behavior on top of a compiled pipeline at run time. A workflow can inject a top-level prompt, override the agent or prompt of specific stages, expand a stage into N chained copies via `loop`, **skip (delete) a stage**, declare per-stage **auto-approval** via `approve`, force or cancel a stage's -**manual launch mode** via `manual`, and **declaratively add -new stages** to the pipeline via `extend`. +**manual launch mode** via `manual`, attach **note buttons** to a stage via +`notes`, **declaratively add new stages** to the pipeline via `extend`, and +configure **project-memory participation** via the top-level `memory` block +and the per-stage `reflect` / `memory` instructions (see +[Project memory (`memory`, `reflect`)](#project-memory-memory-reflect)). Stage names in `workflow.stages` are matched strictly: a name that does not match any pipeline step or extend-stage is a compile error. Workflows that @@ -24,7 +27,7 @@ traversal via `..` or an absolute prefix is rejected. ## Document shape -A workflow-file is a YAML mapping with up to three top-level keys: +A workflow-file is a YAML mapping with up to four top-level keys: ```yaml prompt: | @@ -38,6 +41,15 @@ stages: loop: 2 # optional iteration count (>= 1) skills: [web-search] # optional skills merged with the pipeline stage's skills approve: auto # optional auto-approval directive: auto | plan | dialog + notes: # optional note buttons compiled to the afm `buttons` field + fix: Fix the failure and continue + reflect: # optional memory-reflection instruction (reflect method only) + file: shared.md + memory: true # optional memory participation (alignment method only; never together with reflect) + +memory: # optional workflow-memory configuration block + method: reflect # reflect | alignment (the instruction vocabulary selector) + max_rules: 25 # optional rule cap (>= 1) extend: : @@ -52,17 +64,18 @@ extend: | `prompt` | string | no* | Top-level prompt emitted as the first key of the output. | | `stages` | map | no* | Per-stage override instructions keyed by stage name. | | `extend` | map | no* | New stages to add to the pipeline, keyed by new stage name. | +| `memory` | map | no* | Workflow-memory configuration (see [Project memory](#project-memory-memory-reflect)). | -\* At least one of `prompt`, a non-empty `stages` block, or a non-empty -`extend` block must be present; an empty workflow is rejected with a -structural error. +\* At least one of `prompt`, a non-empty `stages` block, a non-empty +`extend` block, or a `memory` block must be present; an empty workflow is +rejected with a structural error. Unknown top-level keys are rejected with -`unknown key in workflow: ; valid keys: prompt, stages, extend`. +`unknown key in workflow: ; valid keys: prompt, stages, extend, memory`. ## Stage entries -Each entry under `stages` is keyed by stage name and accepts up to seven +Each entry under `stages` is keyed by stage name and accepts up to ten fields: | Field | Type | Default | Description | @@ -74,12 +87,15 @@ fields: | `skip` | bool | — | When `true`, the compiler DELETES this stage from the compiled pipeline (the stage is absent from the flow-file entirely). Dependents of the skipped stage are transparently reconnected to its predecessors (no dangling references). `false` (or an absent key) leaves the stage in place. `skip` is allowed ONLY in the `stages` block — it is a structural error under `extend`. `skip` wins over `agent`/`prompt`/`loop`/`skills` overrides on the same entry. | | `approve` | string | — | Auto-approval directive. Accepted values are `auto`, `plan`, and `dialog`; any other value (or a non-string) is a structural error. Each value drives a subset of two INDEPENDENT effects the compiler applies to the stage body (see [Auto-approval (`approve: auto/plan/dialog`)](#auto-approval-approve-auto-plan-dialog)): (1) **communication effect** — if the body has `communication: true`, the stage's `interactive` output is SUPPRESSED (omitted, not `false`); (2) **roles effect** — if the body's raw `roles` contain `planner`, the stage emits `auto_approve: true`. `auto` drives BOTH effects; `plan` drives only the communication effect; `dialog` drives only the roles effect. Allowed in both `stages` and `extend` (inline default override; a `stages` entry wins per-field). | | `manual` | bool | — | Manual-launch instruction, `stages` block only. `true` forces the manual launch mode: the compiler emits `auto_run: false` for the stage, overriding any authored `trigger` in its body. `false` cancels a manual state coming from either body source (a pipeline-file `trigger: manual` or an extend body `trigger: manual`) and is a structural error (`manual: false on non-manual stage `) when the stage is not manual. An absent key means no instruction — the stage's own `trigger` decides. The three states (`true`/`false`/absent) are distinct; a non-bool value (including `null`) is a structural error. Allowed ONLY in `stages` — it is a structural error under `extend` (a new stage's launch mode is authored in its body via `trigger`). `skip` wins over `manual`: a skipped stage is removed before the manual instruction is applied. See [Manual launch (`manual` and `trigger`)](#manual-launch-manual-and-trigger). | +| `notes` | map of str→str | — | Note buttons — a map of "note name → prompt text" compiled verbatim into the stage's afm `buttons` field (canonical slot after `description`). Single-line texts serialize as plain scalars, multi-line texts as block literals. An empty map equals absence (no `buttons` key emitted). Allowed ONLY in `stages` — it is a structural error under `extend` (an extend-stage receives its buttons through the `stages` block by name). Every `loop`-expanded copy carries the same buttons; `skip` wins over `notes`. Interpretation of the buttons belongs to afm — the compiler only assembles and serializes the field. See [Note buttons (`notes`)](#note-buttons-notes). | +| `reflect` | map | — | Memory-reflection instruction (reflect method): `{file: , mode: r\|w\|rw}` telling afm which memory file the stage reflects into. `file` is required and must be a relative, non-escaping path shape; `mode` defaults to `rw`. Allowed ONLY in `stages` — it is a structural error under `extend`. See [Project memory (`memory`, `reflect`)](#project-memory-memory-reflect). | +| `memory` | bool | — | Memory-participation instruction (alignment method): `true` marks the stage as participating in project memory. An explicit `false` equals absence. Allowed ONLY in `stages` — it is a structural error under `extend`. See [Project memory (`memory`, `reflect`)](#project-memory-memory-reflect). | Rules: -- Only `agent`, `prompt`, `loop`, `skills`, `skip`, `approve`, `manual` are valid. An unknown key - is rejected with `unknown key in workflow.stages.: ; valid keys: - agent, prompt, loop, skills, skip, approve, manual`. +- Only `agent`, `prompt`, `loop`, `skills`, `skip`, `approve`, `manual`, `notes`, `reflect`, `memory` are valid. An + unknown key is rejected with `unknown key in workflow.stages.: ; valid keys: + agent, prompt, loop, skills, skip, approve, manual, notes, reflect, memory`. - `loop` must be an int `>= 1`. Zero, negative values, and non-int types raise a structural error. - `skills` must be a `list[str]`. A non-list (or a list with non-string @@ -97,6 +113,21 @@ Rules: explicit `null`) raises `non-bool value in workflow.stages..manual`. `manual` is allowed only in the `stages` block — it is a structural error under `extend`. +- `notes` (when present) must be a mapping with string values. A non-mapping + value (including an explicit `null`) raises `non-mapping notes in + workflow.stages.`; a non-string value raises `non-str value in + workflow.stages..notes.`. An empty map is treated as absence. + `notes` is allowed only in the `stages` block — it is a structural error + under `extend` (see [Note buttons (`notes`)](#note-buttons-notes)). +- `reflect` (when present) must be a mapping with a key set within + `{file, mode}`; `file` is required and must be a valid path shape, `mode` + one of `r`/`w`/`rw`. `reflect` is allowed only in the `stages` block — + it is a structural error under `extend` (see + [Project memory (`memory`, `reflect`)](#project-memory-memory-reflect)). +- `memory` (when present) must be a bool; an explicit `false` equals absence. + `memory` is allowed only in the `stages` block — it is a structural error + under `extend` (see + [Project memory (`memory`, `reflect`)](#project-memory-memory-reflect)). - The stage value must be a mapping. Non-mapping values raise `non-mapping stage in workflow.stages`. - Stage names are validated against the target pipeline: a name that does not @@ -127,7 +158,7 @@ pipeline itself does not care which concrete CLI is underneath. The canonical baseline wrappers shipped with the image — `claude`, `codex`, `cursor`, `opencode`, `qwen` — and their per-agent environment variables are -documented in [Agents](../configuration/agents.md) under +documented in [Agents](../../configuration/agents.md) under the Configuration reference. This page keeps the workflow-scoped agent semantics (per-stage override, inline-extend agent); the wrapper set itself is shared with build and pipeline. @@ -296,6 +327,113 @@ before the manual instruction is applied, so `skip: true` + `manual: true` on one entry simply deletes the stage. On a `loop`-expanded stage every copy carries the launch mode of the original. +### Note buttons (`notes`) + +A workflow's per-stage `notes` field attaches **note buttons** to a stage: a +map of "note name → prompt text" that the compiler emits verbatim as the +stage's afm `buttons` field (in the canonical slot right after +`description`). + +```yaml +stages: + deploy: + notes: + fix: Fix the failure and continue + investigate: | + Gather diagnostics for the failure. + Include the last 50 log lines. +``` + +- The map passes through verbatim — keys and values unchanged, authoring + order preserved. Single-line texts serialize as plain scalars (quoted as + needed); multi-line texts serialize as block literals. +- Buttons are authored ONLY through this instruction. An authoring + `buttons` key in a stage body (a pipeline-file stage or an `extend` body) + is a structural error (`buttons key is forbidden in stage body; use notes + in workflow.stages`) — there is exactly one authoring source, so the + compiler-assembled field can never collide with an authored one. +- `notes` is allowed ONLY in the `stages` block — under `extend` it is a + structural error (`notes is forbidden in workflow.extend.`). An + extend-stage receives its buttons through the `stages` block by its name, + like any other stage. +- An empty map (`notes: {}`) equals absence — no `buttons` key is emitted. + `notes` changes nothing else: a workflow whose entries are all empty + compiles byte-identically to compiling without a workflow, while any + other instruction (`prompt`, `agent`, `loop`, `skip`, ...) changes the + output as usual — notes or not. +- Every `loop`-expanded copy carries the same buttons, and `skip` wins over + `notes`: a skipped stage is removed before the buttons are resolved, so + its notes never leak into a survivor. +- Interpretation of the buttons belongs to afm — the compiler only assembles + and serializes the field. + +### Project memory (`memory`, `reflect`) + +A workflow can wire its stages into the runner's **project memory**: a +top-level `memory` block selects the method and the block-level settings, +and the per-stage instructions mark which stages participate. + +```yaml +memory: + method: reflect # reflect (default) | alignment + max_rules: 40 # rule cap, >= 1, default 25 + commit: false # whether memory changes are committed + +stages: + brainstorm: + reflect: # reflect method: which file the stage reflects into + file: shared.md + mode: rw # r | w | rw, default rw + accept: + reflect: + file: shared.md +``` + +Under `method: alignment` the participating stages carry `memory: true` instead — see the key table below. + +The top-level block accepts five keys: + +| Key | Type | Default | Description | +|-------------|--------|------------|--------------------------------------------------------------------------------| +| `method` | string | `reflect` | The instruction vocabulary: `reflect` pairs with the per-stage `reflect` instruction, `alignment` with the per-stage `memory` instruction. Never part of any output. | +| `path` | string | — | Suffix inside the fixed memory root `.goga/memory`. Must be a relative, non-escaping path shape. | +| `max_rules` | int | `25` | The maximum number of memory rules (`>= 1`). | +| `commit` | bool | `false` | Whether memory changes are committed. | +| `mode` | string | `rw` under `alignment` | The project-memory access mode (`r`/`w`/`rw`). Authored ONLY under `method: alignment` — an authored `mode` together with `method: reflect` is a structural error. | + +Behavior rules: + +- **The method selects the instruction vocabulary.** Under `reflect` (the + default when no block is authored) a stage participates by carrying a + `reflect: {file, mode?}` instruction; under `alignment` it participates by + carrying `memory: true`. The two vocabularies never mix: a `reflect` + instruction under `alignment`, or a `memory: true` instruction under + `reflect` (including with no block at all), is a structural error. +- **The block is emitted iff at least one stage participates.** A memory + configuration without participation is a silent no-op — no block, no stage + keys, not even an opting-out stage key. A workflow consisting of the + `memory` block alone is still valid (it counts as content). +- Participation is counted over the **working body** — after skip removal + and loop expansion, embedded extend-stages included. A skipped stage's + instructions die with it: skipping the only participating stage disables + the block entirely. Every `loop`-expanded copy carries the same memory + keys as its original. +- Project memory lives under the fixed root `.goga/memory` (plus the + authored `path` suffix when one is set). How the memory settings and the + per-stage participation are encoded into the compiled flow-file is an + internal contract of the compiler — see + `goga/pipeline/compiler/.usages/memory-emission.md`; this documentation + intentionally does not pin the compiled form. +- Both instructions are allowed ONLY in the `stages` block — under `extend` + they are structural errors. A new stage participates through a + `stages`-block entry authored under its name. Authoring a `reflect` or + `memory_use` key in any stage body (a pipeline-file stage or an extend + body) is likewise a structural error — the memory stage keys come from + the workflow instructions alone. +- The memory mechanism is interpreted by the afm runtime (the shipped + image carries afm 0.5.60+, which the memory mechanism requires) — goga + only authors and compiles the instructions. + ## Extending the pipeline with new stages The `stages` block only overrides stages that already exist in the target @@ -475,7 +613,8 @@ compiler reconstructs the parsed body in a fixed sequence of passes **before** building the output stages. The ordering is mandatory: extend-stages are embedded first, stage names are strictly validated and skipped stages removed, then per-stage overrides are applied, then loops are expanded, then external -`depends_on` references are rewritten. Embedding first means a per-stage +`depends_on` references are rewritten, and finally memory participation is +computed over the finished working body. Embedding first means a per-stage override (Pass 1) or loop expansion (Pass 2) can also target a stage introduced by `extend`, by name. @@ -634,6 +773,32 @@ the stage's own agent-mode resolution are independent — the override selects which agent binary runs the stage, while the `roles` field selects how the work is organized inside it. +### Pass 4.9 — Memory participation + +After the working body is final (skip removal, loop expansion, and the +external `depends_on` rewrite have all run), the compiler computes memory +participation from the workflow's effective memory configuration — the +authored `memory` block when present, else the materialized defaults +(`method: reflect`, `max_rules: 25`, `commit: false`). + +- Under the **reflect** method a stage participates when it carries a + `reflect` instruction; under the **alignment** method when it carries + `memory: true`. Participation is looked up per base name in the working + body, so every `loop`-expanded copy inherits its base's verdict and a + skipped stage never counts. +- The memory settings apply **iff at least one stage participates** — a + configuration without participants is a silent no-op: the compiled + output carries no memory keys at all. +- How the configuration and the per-stage participation are encoded into + the flow-file is an internal contract of the compiler (see + `goga/pipeline/compiler/.usages/memory-emission.md`) — this documentation + intentionally does not pin the compiled form. +- Memory application is output-side only — the source pipeline-file is + never touched, and an authoring `reflect` or `memory_use` key in any + stage body is a structural error. A workflow without memory + participation compiles byte-identically to the same workflow compiled + before the mechanism existed. + ## Invocation modes A pipeline run picks up a workflow in one of three mutually exclusive modes. @@ -659,7 +824,7 @@ The card form honors the same three modes: `goga pipeline deploy --info rule set, with the same host-side validation. The decision travels as `docker run` argv (not the env-file), and the stage list the card prints is exactly the composition a run with the same flags executes. See -[pipeline](../cli/pipeline.md). +[pipeline](cli.md). ### Log line @@ -776,7 +941,7 @@ untouched — `extend` layers new stages on top at run time. | Root is not a mapping | `workflow must be a mapping` | | `prompt` present but not a string | `non-str value in workflow.prompt` | | `stages` present but not a mapping | `non-mapping stages block in workflow` | -| Unknown top-level key | `unknown key in workflow: ; valid keys: prompt, stages, extend` | +| Unknown top-level key | `unknown key in workflow: ; valid keys: prompt, stages, extend, memory` | | Stage value is not a mapping | `non-mapping stage in workflow.stages` | | `extend` present but not a mapping | `non-mapping extend block in workflow` | | Extend entry value is not a mapping | `non-mapping extend entry in workflow.extend` | @@ -789,7 +954,7 @@ untouched — `extend` layers new stages on top at run time. | Inline `approve` in an extend entry not a string | `non-str value in workflow.extend..approve` | | Inline `approve` in an extend entry not one of `auto`/`plan`/`dialog` | `approve must be one of: auto, plan, dialog in workflow.extend.` | | Extend entry has neither `before` nor `after` | `extend entry requires at least one of before/after` | -| Unknown per-stage key | `unknown key in workflow.stages.: ; valid keys: agent, prompt, loop, skills, skip, approve, manual` | +| Unknown per-stage key | `unknown key in workflow.stages.: ; valid keys: agent, prompt, loop, skills, skip, approve, manual, notes, reflect, memory` | | `agent` present but not a string | `non-str value in workflow.stages..agent` | | `prompt` present but not a string | `non-str value in workflow.stages..prompt` | | `loop` present but not an int | `non-int value in workflow.stages..loop` | @@ -801,16 +966,43 @@ untouched — `extend` layers new stages on top at run time. | `approve` present but not one of `auto`/`plan`/`dialog` | `approve must be one of: auto, plan, dialog in workflow.stages.` | | `skip` present under `extend` | `skip is forbidden in workflow.extend.` | | `manual` present under `extend` | `manual is forbidden in workflow.extend.` | +| `notes` present under `extend` | `notes is forbidden in workflow.extend.` | +| `notes` present but not a mapping (including `null`) | `non-mapping notes in workflow.stages.` | +| `notes` value not a string | `non-str value in workflow.stages..notes.` | | `manual: false` on a stage that is not manual | `manual: false on non-manual stage ` | | Unknown stage name in `workflow.stages` (absent from pipeline and extend) | `unknown stage name in workflow.stages: ` | | Unknown ref in `workflow.extend..before` | `unknown stage name in workflow.extend..before: ` | | Unknown ref in `workflow.extend..after` | `unknown stage name in workflow.extend..after: ` | | All stages skipped (empty reconstructed body) | `empty body` | -| None of `prompt`, `stages`, `extend` entries are present | `empty workflow — provide at least prompt, one stage, or one extend entry` | +| None of `prompt`, `stages`, `extend`, `memory` entries are present | `empty workflow — provide at least prompt, one stage, one extend entry, or the memory block` | +| `memory` present but not a mapping | `non-mapping memory block in workflow` | +| Unknown key in the `memory` block | `unknown key in workflow.memory: ; valid keys: method, path, max_rules, commit, mode` | +| `memory.method` not a string | `non-str value in workflow.memory.method` | +| `memory.method` not `reflect`/`alignment` | `method must be one of: reflect, alignment in workflow.memory` | +| `memory.path` not a string | `non-str value in workflow.memory.path` | +| `memory.path` empty, absolute, or containing `..` | `invalid path in workflow.memory.path: ` | +| `memory.max_rules` not an int (bool counts as non-int) | `non-int value in workflow.memory.max_rules` | +| `memory.max_rules` an int but `< 1` | `max_rules must be >= 1 in workflow.memory` | +| `memory.commit` not a bool | `non-bool value in workflow.memory.commit` | +| `memory.mode` not a string | `non-str value in workflow.memory.mode` | +| `memory.mode` not `r`/`w`/`rw` | `mode must be one of: r, w, rw in workflow.memory` | +| `memory.mode` authored under `method: reflect` | `mode is forbidden in workflow.memory with method: reflect` | +| `reflect` present but not a mapping | `non-mapping reflect in workflow.stages.` | +| Unknown key in a `reflect` instruction | `unknown key in workflow.stages..reflect: ; valid keys: file, mode` | +| `reflect` without `file` | `file is required in workflow.stages..reflect` | +| `reflect.file` not a string | `non-str value in workflow.stages..reflect.file` | +| `reflect.file` empty, absolute, or containing `..` | `invalid path in workflow.stages..reflect.file: ` | +| `reflect.mode` not a string | `non-str value in workflow.stages..reflect.mode` | +| `reflect.mode` not `r`/`w`/`rw` | `mode must be one of: r, w, rw in workflow.stages..reflect` | +| `memory` per-stage instruction not a bool | `non-bool value in workflow.stages..memory` | +| `reflect` authored under `method: alignment` | `reflect is forbidden in workflow.stages. with method: alignment` | +| `memory: true` authored under `method: reflect` (or no block) | `memory is forbidden in workflow.stages. with method: reflect` | +| `reflect` present under `extend` | `reflect is forbidden in workflow.extend.` | +| `memory` present under `extend` | `memory is forbidden in workflow.extend.` | ## See also - [Pipeline File](pipeline-file.md) — the base document a workflow layers on top of. -- [`goga pipeline` CLI reference](../cli/pipeline.md) — invocation flags +- [`goga pipeline` CLI reference](cli.md) — invocation flags for `--workflow` / `--no-workflow` and exit codes. diff --git a/docs/features/schema/api.md b/docs/features/schema/api.md new file mode 100644 index 00000000..9b372e3f --- /dev/null +++ b/docs/features/schema/api.md @@ -0,0 +1,20 @@ +# Schema — API + +The facade of the domain package **`goga.schema`** — the JSON schema tree generation from project CODEMANIFEST files. + +The signature below is the CODEMANIFEST contract of the cell. + +```python +schema(cells: list[str], max_depth: int | None, depends_on: list[str]) -> str +``` + +Walk the project cells and emit the JSON schema tree. `cells` — the positional scope (the named cells only); `max_depth` — the bound on the import expansion depth; `depends_on` — keep only the cells connected to the named ones. The returned string is the JSON document the `goga schema` command prints: every declared entity and routine with its signature, location, annotations, methods, and properties. + +## Example + +```python +from goga.schema import schema + +doc = schema(cells=[], max_depth=None, depends_on=[]) +print(doc) +``` diff --git a/docs/cli/schema.md b/docs/features/schema/cli.md similarity index 100% rename from docs/cli/schema.md rename to docs/features/schema/cli.md diff --git a/docs/features/schema/configuration.md b/docs/features/schema/configuration.md new file mode 100644 index 00000000..f03b4c97 --- /dev/null +++ b/docs/features/schema/configuration.md @@ -0,0 +1,5 @@ +# Schema — Configuration + +The schema domain reads **no section of `.goga/config.yml`** — the export is fully scoped by the CLI arguments (the positional cells, `--max-depth`, `--depends-on`). + +The cells it walks come from the project structure itself (every directory with a `CODEMANIFEST` — see [Cell](../../cell/index.md)). The general configuration model of the product is covered in [Configuration](../../configuration/index.md). diff --git a/docs/features/schema/hooks.md b/docs/features/schema/hooks.md new file mode 100644 index 00000000..cb9e3a5d --- /dev/null +++ b/docs/features/schema/hooks.md @@ -0,0 +1,5 @@ +# Schema — Hooks + +The schema domain exposes **no hook actions** for tool packages today. + +A tool that needs the project structure receives it through the optional AST injection of its `main` entry point instead (see [Tools — CLI](../tools/cli.md#optional-injections)). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/schema/index.md b/docs/features/schema/index.md new file mode 100644 index 00000000..9fe99220 --- /dev/null +++ b/docs/features/schema/index.md @@ -0,0 +1,16 @@ +# Schema + +Generate a JSON schema tree from project CODEMANIFEST files. + +The schema domain exposes the contract layer to schema consumers. Which tasks it solves: + +- **Export the contract** — `goga schema` walks the project cells and emits a JSON tree of the declared types: every entity and routine with its signature, location, annotations, methods, and properties. +- **Scope the export** — positional cells limit the output to the named cells; `--max-depth N` bounds the import expansion; `--depends-on CELL` keeps only the cells connected to the given one. +- **Feed tooling** — the JSON output is the machine-readable projection of the CODEMANIFEST layer: viewers, generators, and external validators consume it instead of re-parsing the DSL. + +## In this directory + +- [CLI](cli.md) — the full `goga schema` command reference +- [Configuration](configuration.md) — the domain reads no configuration section +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.schema` package facade diff --git a/docs/features/tools/api.md b/docs/features/tools/api.md new file mode 100644 index 00000000..8b831a71 --- /dev/null +++ b/docs/features/tools/api.md @@ -0,0 +1,23 @@ +# Tools — API + +The facade of the domain package **`goga.commands.tool`** — the dynamic invocation of an installed tool package. + +The signatures below are the CODEMANIFEST contract of the cell. + +```python +tool(name: str, args: list[str]) +build_injections(main: Callable) -> dict[str, object] +``` + +- `tool` — resolve the `goga_tool_` package installed in the running interpreter, import it, and call its `main` facade with `args`. The call is transparent: the tool's output and exit behavior pass through unchanged; a missing package or a broken import surfaces as a clean CLI error. +- `build_injections` — inspect a tool's `main` callable and build the opt-in injection values for the parameters it declares (the keyword-capable `ast` parameter receives the project AST). A `main` that declares no injections receives an empty mapping and the AST is never built. + +A tool package's own facade API — `main(argv)`, `install(user)`, `register_hooks(hooks)` — is authored by the tool; the contract of each callback is covered in [Hooks](hooks.md). + +## Example + +```python +from goga.commands.tool import tool + +tool("mkdocs", ["--help"]) +``` diff --git a/docs/cli/tool.md b/docs/features/tools/cli.md similarity index 100% rename from docs/cli/tool.md rename to docs/features/tools/cli.md diff --git a/docs/features/tools/configuration.md b/docs/features/tools/configuration.md new file mode 100644 index 00000000..fbbe41ff --- /dev/null +++ b/docs/features/tools/configuration.md @@ -0,0 +1,7 @@ +# Tools — Configuration + +The tools domain reads **no dedicated section of `.goga/config.yml`**. + +The `tools:` mapping of the project configuration — the version declarations consumed by `goga install` in bulk mode — belongs to the [Install](../install/configuration.md) domain (the command that reads it). A tool package's own behavior is configured by nothing in the project: it receives its inputs through the CLI invocation, the optional AST injection, and the hook contexts. + +The general configuration model of the product is covered in [Configuration](../../configuration/index.md). diff --git a/docs/features/tools/hooks.md b/docs/features/tools/hooks.md new file mode 100644 index 00000000..c65a9b39 --- /dev/null +++ b/docs/features/tools/hooks.md @@ -0,0 +1,18 @@ +# Tools — Hooks + +The tools domain is the **consumer side** of the hooks platform: a tool package declares the callbacks, the domains fire the actions. + +A tool package may expose two lifecycle callables in its facade, next to the required `main(argv)`: + +| Callable | Called by | Purpose | +|---|---|---| +| `register_hooks(hooks)` | a domain checkpoint, or `goga hooks` | Subscribe hooks to domain actions — `hooks.subscribe(domain, action, name, hook)`; registration is never cached, package edits apply from the next run | +| `install(user: str \| None = None)` | `goga install`, after a successful pip | The post-install lifecycle hook (see [Install — CLI, post-install hooks](../install/cli.md#post-install-hooks)) | + +```python +# inside the goga_tool_ package +def register_hooks(hooks): + hooks.subscribe("statuses", "register_statuses", "published", register_published) +``` + +The full registration contract — the hook signature (`context` / `self`), the error classes, the diagnostics — is the [Hooks domain](../hooks/hooks.md); the declared actions are listed per domain (today: [History — Hooks](../history/hooks.md)). The `main` entry point and its optional AST injection are covered in [CLI](cli.md#optional-injections). diff --git a/docs/tools.md b/docs/features/tools/index.md similarity index 74% rename from docs/tools.md rename to docs/features/tools/index.md index 03f7906a..0ef202bf 100644 --- a/docs/tools.md +++ b/docs/features/tools/index.md @@ -1,6 +1,14 @@ # Tools -Tools extend goga with specialized capabilities. Each tool is a separate Python package that installs skills into your AI agent and provides CLI commands. +A **tool** is a pluggable capability package: a separate Python package under the `goga_tool_` prefix that installs skills into your AI agent, ships CLI commands, optionally carries pipeline-files, and can extend goga domains with hooks. + +The tools domain covers the ecosystem itself. Which tasks it solves: + +- **Use a tool** — `goga tool ` invokes a tool's CLI directly; the `/goga:tool ` slash command (or the dispatcher skill) invokes it from an agent session. +- **Package your own tool** — the standard layout (`skills/`, optional `pipelines/`, the `main(argv)` facade) and the naming rules that keep skills and pipelines collision-free. +- **Extend domains** — a tool may expose a `register_hooks` callback and subscribe to domain actions (see [Hooks](hooks.md) and the [Hooks](../hooks/index.md) domain). + +Installing and removing tool packages is the [Install](../install/index.md) domain; the built-in tools (`viewer`, `mkdocs`, `scriba`) ship out of the box. ## Installing a tool @@ -17,7 +25,7 @@ goga install --version 1.0.x goga install ``` -See [`goga install`](cli/install.md) for the version grammar and single / bulk / empty modes. +See [`goga install`](../install/cli.md) for the version grammar and single / bulk / empty modes. After installing, connect the tool to your agent: @@ -27,7 +35,7 @@ goga connect If you have already connected an agent, `goga install` automatically re-syncs every connected agent after a successful pip, so the new tool's skills and pipelines appear immediately — no separate `goga connect` call is needed. `goga connect` is only required the first time (or to connect a new agent); pass `goga install --no-connect` to opt out of activation. -`goga connect` auto-discovers all installed `goga_tool_*` packages and installs their skills centrally into `~/.goga/skills/`, then symlinks them into each connected agent's skills directory. If the package ships any pipeline `*.yml` files under `pipelines/`, those are installed into `~/.goga/pipelines/` in the same step, **namespaced as `:.yml`** so they are addressable as `goga pipeline :` (internal pipelines stay un-prefixed) — see [Pipelines / Shipped Pipelines](pipelines/shipped.md) for the namespacing and residual-conflict rules. The tool becomes available both as an agent skill and as a CLI command. +`goga connect` auto-discovers all installed `goga_tool_*` packages and installs their skills centrally into `~/.goga/skills/`, then symlinks them into each connected agent's skills directory. If the package ships any pipeline `*.yml` files under `pipelines/`, those are installed into `~/.goga/pipelines/` in the same step, **namespaced as `:.yml`** so they are addressable as `goga pipeline :` (internal pipelines stay un-prefixed) — see [Pipelines / Shipped Pipelines](../pipelines/shipped.md) for the namespacing and residual-conflict rules. The tool becomes available both as an agent skill and as a CLI command. ## Removing a tool @@ -50,7 +58,7 @@ goga uninstall --user alice After a successful pip uninstall, every connected agent is re-synced: the removed tool's skills and pipelines disappear from `~/.goga/` and from each agent's symlink tree. A tool removed by hand with plain pip leaves those artifacts behind until the next re-sync. -See [`goga uninstall`](cli/uninstall.md) for the full confirmation, sudo/user, and exit-code semantics. +See [`goga uninstall`](../install/uninstall.md) for the full confirmation, sudo/user, and exit-code semantics. ## Built-in tools @@ -80,7 +88,7 @@ Each tool package follows a standard layout: ``` goga_tool_/ -├── __init__.py # main(argv) entry point for CLI +├── __init__.py # main(argv) CLI entry; optional install()/register_hooks() ├── skills/ # Required — at least one skill │ └── / │ └── SKILL.md # Agent skill definition @@ -95,6 +103,20 @@ A valid tool must: - Each skill directory must include a `SKILL.md` file - Expose a `main(argv: list[str])` function for CLI execution +A tool package may define three facade callbacks, separated by nature: +`main` (the CLI call of the tool — execution), `install` (the post-install +lifecycle hook), and `register_hooks` (the domain-extension registration, +see [Hooks](hooks.md)). `main` is required; the +other two are optional. + +A tool **may** additionally expose an `install(user: str | None = None)` +callable in its facade package — the post-install hook. `goga install` calls +it after a successful pip, passing the initiating user (`SUDO_USER` when goga +itself runs under sudo, else the current OS user) only when the parameter is +declared keyword-capable; otherwise the hook is called with no arguments. A +missing or non-callable `install` is skipped quietly. See +[`goga install` — Post-install hooks](../install/cli.md#post-install-hooks). + A `pipelines/` directory is **optional**. When present, `goga connect` copies its flat `*.yml` files into `~/.goga/pipelines/` **namespaced as `:.yml`** (where `` is the package name with the @@ -104,7 +126,7 @@ internal-source pipelines. Namespacing structurally prevents collisions with int pipelines and between two tools shipping the same name; only a residual conflict on the namespaced destination is possible, resolved with the same `--force-overwrite` semantics used for tool-skill installation. See -[Pipelines / Shipped Pipelines](pipelines/shipped.md) for the full +[Pipelines / Shipped Pipelines](../pipelines/shipped.md) for the full installation algorithm. ## Optional injections @@ -113,7 +135,7 @@ installation algorithm. project AST (loaded lazily from the current project root, only when declared). A tool that does not need the AST keeps the minimal `main(argv)` form and the AST is never built. Validation errors in the loaded tree pass through to the -tool unchanged. See [goga tool — Optional injections](cli/tool.md#optional-injections) +tool unchanged. See [goga tool — Optional injections](cli.md#optional-injections) for the entry-point forms and opt-in rules. ## Skill naming @@ -152,4 +174,11 @@ The `` prefix is the canonical hyphenated tool name — the package name w - Name pipeline files with lowercase and hyphens as separators - Use flat `*.yml` files directly under `pipelines/` — no subdirectories - Rely on the automatic `:` prefix for namespacing; never bake the tool name into the filename yourself -- A residual conflict on the namespaced destination (the same `:.yml` already exists) is resolved with `goga connect --force-overwrite` — see [Pipelines / Shipped Pipelines](pipelines/shipped.md) \ No newline at end of file +- A residual conflict on the namespaced destination (the same `:.yml` already exists) is resolved with `goga connect --force-overwrite` — see [Pipelines / Shipped Pipelines](../pipelines/shipped.md) + +## In this directory + +- [CLI](cli.md) — the `goga tool` command reference +- [Configuration](configuration.md) — the `tools:` section of `.goga/config.yml` +- [Hooks](hooks.md) — the tool-package side of domain extension +- [API](api.md) — the `goga.commands.tool` package facade diff --git a/docs/features/topics/api.md b/docs/features/topics/api.md new file mode 100644 index 00000000..86ac900a --- /dev/null +++ b/docs/features/topics/api.md @@ -0,0 +1,86 @@ +# Topics — API + +The facade of the domain package **`goga.topics`** — the work-tracker view of the history tree. Git access lives in the nested leaf cell `goga.topics.git`, the interactive todo entry in `goga.topics.editor`; both surface through this facade's routines. Identity, addressing, and statuses come from `goga.history` (see [History — API](../history/api.md)). + +The signatures below are the CODEMANIFEST contract of the cell. + +## Board + +```python +collect_topic_board(year: str | None = None, remote: bool = False) -> list[BoardRecord] +``` + +Collect the cross-branch topic inventory of one year — every topic with its hosting branch, statuses, and todo summary. `year` as four digits (`None` — the current year); `remote=True` reads remote-tracking refs instead of local branches. Records sort by scale order of the first maximal status, then alphabetically by topic. + +```python +BoardRecord(topic: str, branch: str, statuses: list[str], current: bool, + remote: bool, todo: str | None = None) +``` + +One row of the board. `topic` — the slug; `branch` — the display name of the hosting branch; `statuses` — the qualified names of the maximal present statuses in scale order; `current` — the row hosts the current working branch; `remote` — the hosting ref is remote-tracking; `todo` — the todo summary (the first non-empty line of `todo.md` after `#` markers are stripped) or `None`. + +## Switching and ensuring + +```python +resolve_switch_candidates(identifier: str, year: str | None = None) -> list[SwitchCandidate] +switch_topic(identifier: str, todo: bool = False, year: str | None = None) -> str +ensure_topic(identifier: str, todo: bool = False, year: str | None = None) -> str +``` + +`resolve_switch_candidates` resolves an identifier through the three tiers — exact branch name, exact topic slug (local before remote), prefix — and returns the candidates. `switch_topic` brings the repository onto the hosting branch (a `ValueError` carries ambiguity and clean-failure reasons). `ensure_topic` is the combined orchestration: it switches onto hosted work and creates fresh work from the current HEAD when nothing hosts the identifier — the routine behind `goga pipeline -t`. With `todo=True` the external editor opens the topic's `todo.md` after the switch. Each returns its single result line. + +```python +SwitchCandidate(branch: str, topic: str | None, statuses: list[str], + current: bool, remote: bool) +``` + +One resolution candidate — `topic` is `None` for a branch hosting no topic. + +## Creation + +```python +create_topic(branch_name: str, base_ref: str, todo: str | None = None, + publish: bool = False, commit_message: str | None = None, + year: str | None = None, switch: bool = False) -> str +``` + +Create fresh work — a branch named verbatim at `base_ref` with the topic of the year. The default path plants one quarantined commit carrying the topic's `todo.md` (git plumbing, the working copy untouched) — the todo is required there. `switch=True` checks the branch out instead (the topic directory lands uncommitted, the todo optional). `publish=True` builds the same one-commit branch and pushes it to `origin` without switching; `commit_message` is the publication-only commit template. Returns the result line. + +```python +enter_topic_todo(topic: str, year: str | None = None) -> bool +publish_topic(branch_name: str, todo: str, base_ref: str, + commit_message: str | None = None, year: str | None = None) -> str +``` + +`enter_topic_todo` opens the external editor on the topic's `todo.md` (`True` — saved, `False` — cancelled). `publish_topic` is the fast creation-and-publication cycle. + +## Occupancy oracles + +```python +check_branch_occupancy(branch_name: str, slug: str, year: str | None = None) -> str | None +check_slug_occupancy(slug: str, year: str | None = None) -> str | None +``` + +Read-only probes: the first returns the conflict reason when the branch name or the topic is already occupied, the second when the slug's topic directory or hosted branch exists. `None` — free. + +## Deletion + +```python +resolve_delete_targets(identifiers: list[str], year: str | None = None) -> list[DeleteTarget] +delete_topics(targets: list[DeleteTarget], year: str | None = None) -> str +``` + +`resolve_delete_targets` resolves every identifier first (all-or-nothing; a `ValueError` carries ambiguity, merged work, and current-branch reasons). `delete_topics` removes each target's local branch, `origin` twin, and topic directory; a rejected remote deletion restores the failing target's local branch. `DeleteTarget(topic, branch, remote, has_dir)` carries the resolved target. + +## Example + +```python +from goga.topics import collect_topic_board, ensure_topic + +for record in collect_topic_board("2026"): + print(record.topic, record.branch, record.statuses) + +# onto the branch hosting feat-x — or fresh work when nothing hosts it +line = ensure_topic("feat-x") +print(line) # e.g. "Switched to branch feat/x" +``` diff --git a/docs/features/topics/cli.md b/docs/features/topics/cli.md new file mode 100644 index 00000000..d23eeb34 --- /dev/null +++ b/docs/features/topics/cli.md @@ -0,0 +1,175 @@ +# goga topics + +Work with the topics of one year — the cross-branch inventory, fresh-work creation, switching, and deletion. + +`goga topics` is a Click group with four subcommands (`board`, `create`, `switch`, `delete`) over the topics domain. It is host-side and git-driven: the board and the deletion resolution read branch trees without checkout, and creation and switching perform bounded local git mutations. The only network operations are the `--publish` push and the delete push (one per target that has an origin twin); no fetch ever happens; every other mutation is local. + +## Synopsis + +```bash +goga topics [--year YYYY] board [--remote] [--info] +goga topics [--year YYYY] create BRANCH_NAME [--todo TEXT] [--switch] [--publish] [--base-ref REF] [--from-current] [--commit TEMPLATE] +goga topics [--year YYYY] switch IDENTIFIER [--todo] +goga topics [--year YYYY] delete IDENTIFIER... [--yes] +``` + +`--year`/`-y` scopes every subcommand to one four-digit year (default: the current year). The year is never printed. + +## `goga topics board` + +Prints the board — the cross-branch topic inventory of the scoped year — as a three-column table: topic, branch, statuses. + +``` +| Topic | Branch | Statuses +|----------------|----------------|------------------- +| feat-b | feat-b | [defined] +|----------------|----------------|------------------- +| * feat-a | feat-a | [planned] +|----------------|----------------|------------------- +| feat-a | feat-b | [planned] +|----------------|----------------|------------------- +``` + +- One row per topic hosted by a branch; `*` marks the row hosting the current branch. +- A row divider — the same dash run as under the header — closes every record, the last included; a record's wrapped status lines stay undivided. +- The current branch's row reads the working copy — uncommitted progress is visible; every other row reads the branch's committed tree (no checkout happens). +- A local branch and its remote twin collapse to one row — the local branch wins; a topic hosted only by a remote-tracking ref keeps its row with the remote name in the branch column. +- Rows sort by scale order of the first maximal status, then alphabetically by topic. +- `--remote`/`-r` reads remote-tracking refs instead of local branches; the current branch shows through its remote twin. +- `--info`/`-i` adds the todo column between branch and statuses — the first line of the topic's `todo.md` that yields text after leading `#` markers are stripped and the edges trimmed; a topic without a `todo.md`, or one whose every line reduces to emptiness, renders an empty cell. The working copy reads the file directly; every other row reads it from the branch's tree (no checkout). +- The statuses column wraps onto continuation lines when the segments overflow the terminal width; the table never exceeds the width except on terminals below the narrow threshold of the active column rule — 33 columns for the three-column table, 44 with `--info` — where every column keeps a minimum of 8. +- An empty board prints nothing and exits 0 — a year without topics is not an error. + +The statuses are the topic's **maximal present statuses** in scale order — `empty, todo, defined, discovered, backlog, designed, specified, planned, done`, deepening as `todo.md`, `prd.md`, `adr.md`, `task.md`, `arch.md`, `design.md`, `plan.md`, and `completed/plan.md` land. Tool packages can add their own statuses, shown qualified (`mkdocs.published`); see [Tools](../tools/index.md). + +## `goga topics create` + +Creates fresh work — a branch named exactly as entered, planted at a base commit with the topic of the scoped year committed on it, while you stay on your branch: + +```bash +goga topics create Feature/Foo_Bar --from-current --todo "Payment retry" +# Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar +# (one commit on the branch carries .goga/history/2026/feature-foo-bar/todo.md; +# the working copy, the index, and HEAD stay untouched — no switch) + +goga topics create Feature/Foo_Bar --base-ref origin/main --switch +# Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar +# (the branch is checked out; .goga/history/2026/feature-foo-bar/ now exists +# in the working copy; on a terminal the publication ask appears first) +``` + +- The branch name is taken verbatim; git itself rejects invalid names. The default path builds one quarantined commit carrying the topic's `todo.md` on top of the resolved base commit — git plumbing that never touches the working copy, so a dirty tree and a detached HEAD do not interfere — and plants the branch at it (`git update-ref --stdin`); no switch happens, and `goga topics switch ` brings you onto the work later. `-s`/`--switch` plants the branch at the base and checks it out instead (`git switch`) — a failed checkout rolls the planted branch back so the name never strands — with the topic directory created in the working copy, uncommitted. +- The base resolves as `--base-ref` > `topics.base_ref` in `.goga/config.yml` > the current HEAD under `--from-current` > clean error. With nothing set, exit 1 with a message naming the flag, the flag alternative, and the configuration line, including a two-line YAML example (see [Configuration](configuration.md)). +- The topic directory is `.goga/history///`, where the slug is the normalized name (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`). The default path carries the directory as the committed `todo.md`; `--switch` creates it on disk. No artifact file is written unless a todo resolves. +- `-t`/`--todo` takes the todo value on the command line — the multi-line text as entered plus one trailing newline, UTF-8 — which marks the topic `todo` on the status scale and feeds the `--info` column of the board. An empty value — `--todo ""`, `--todo=`, `-t ""` — counts as absent: no `todo.md` is ever created empty. +- The todo is **required** on the default path — git keeps no empty directories, so the work exists only through its committed `todo.md`. A cancelled editor entry or a missing todo exits 1 with `the local creation needs a todo — the board reads the topic through todo.md; pass --todo/-t or --switch/-s to create on the spot without one`. Under `--switch` the todo is optional. +- `--switch` acts only without `--publish` — the publication never switches, so the two together are a clean error (exit 1). +- The current branch already hosting the same slug is a clean error (exit 1) — `branch already hosts topic / — switch to it instead of re-creating it`. There is no idempotent path. +- Occupancy is probed against three oracles in order: a local branch with the entered name, a remote-tracking branch with the entered name (local refs only — no network), and an existing `.goga/history///` directory (only a directory occupies a topic). A fourth oracle applies to every creation: any branch tree of the inventory — local and remote-tracking refs — hosting the topic directory of the slug (`topic '' of is already hosted by branch ''`). +- An occupied name, an unresolvable base, or a name that normalizes to an empty slug (a fully non-ASCII name) is one clean error (exit 1) with the reason and a hint to `goga topics board` for occupied names — there is no re-ask. Every read-only decision (the preflight) runs before the first input, so a failing base never wastes an entered todo. + +### Editor todo entry + +Running the creation with no `--todo` given on an interactive terminal opens the external editor instead of taking the text from the command line. The option takes a value only — a value-less `--todo`/`-t` is click's own usage error (exit 2), not the entry form: + +``` +$ goga topics create feat/x --from-current --switch +Enter the text. An empty or unchanged file cancels the entry. +# (the editor opens; saving writes todo.md, cancelling leaves nothing) +# Created branch feat/x and topic 2026/feat-x +``` + +- The editor resolves through `$VISUAL` → `$EDITOR` → the system default (`vi`); the session edits a temporary file outside the project. +- Saving a blank file — or a file unchanged from its prefill — cancels the entry: under `--switch` the command continues with no `todo.md` written; on the default path the creation is a clean error asking for the todo (see above). A failed editor run is a clean error with nothing mutated. +- Without an interactive terminal a creation with no `--todo` value is a clean error before any mutation: `the todo needs a value — pass --todo/-t or run the creation on an interactive terminal` (exit 1). + +### The publication ask + +On an interactive terminal, without `--publish`, once a todo is resolved, the command asks once: `Publish the branch to origin?`. An empty answer reads the default no — the local path runs (the quarantined branch, or the checked-out branch under `--switch`); answering yes takes the publication path below; Ctrl-C or EOF aborts with nothing created. Without a terminal, or with a cancelled todo entry, no ask happens and the local path runs. + +### `--publish` — create and publish in one step + +`-p`/`--publish` takes the publication path without the ask: it builds the branch off the resolved base, commits only the topic's `todo.md` on it, and pushes it to `origin` — while you stay on your branch: + +```bash +goga topics create Feature/Foo_Bar --publish --todo "Payment retry" +# Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar +``` + +- The working copy, the index, and HEAD stay untouched — the commit is built through quarantined git plumbing, so a dirty tree and a detached HEAD do not interfere; the topic directory is never created on disk. +- The branch carries exactly one commit — the todo file at `.goga/history///todo.md` — and is pushed to `origin` with upstream binding (`git push -u`, exactly that one branch). The topic appears on the remote board with the `todo` status. +- A todo is **required** under `--publish` (the board reads the topic through the todo file). An omitted `--todo` resolves through the editor entry first; a missing or cancelled todo exits 1 with `the publication needs a todo — the board reads the topic through todo.md`. +- Commit template: `--commit`/`-c` > `topics.publish_commit` > the built-in default `goga: create topic {slug}`. `{slug}` is replaced with the topic slug; a template without the placeholder is used verbatim. `--commit` without `--publish` is a clean error (exit 1) — it acts only together with `--publish`. +- `origin` must be configured (exit 1 otherwise, before any mutation). The repository git identity must be set — `commit-tree` needs an author. +- A failed push rolls back fully: the planted branch is deleted, nothing else was ever mutated, and git's push reason surfaces as one clean error (`git failed: `, exit 1). A re-run with the same name then succeeds. + +## `goga topics switch` + +Brings the repository onto the branch hosting the requested work: + +```bash +goga topics switch feat-x +# Switched to branch feat/x + +goga topics switch feat-x --todo +# Switched to branch feat/x +# (the editor opens with the topic's todo.md; saving overwrites it, no commit) +``` + +IDENTIFIER resolves through three tiers — the first tier with a match wins, so a unique identifier never reaches a prompt: + +1. exact branch name; +2. exact topic slug (local branches before remote-tracking refs); +3. prefix — a branch whose name, or whose hosted slug, starts with the input. + +- Several candidates on an interactive terminal: the numbered list with each candidate's statuses is printed and a number is prompted; with no terminal, the numbered list itself is the error (exit 1). +- No candidate at all: exit 1 with a hint to run `goga topics board`. +- Already on the hosting branch: idempotent success — `Already on branch ` — with no working-tree probe and no mutation. +- A local host is checked out (`git switch `); a remote-only host creates the local branch from the remote-tracking ref (`git switch -c /`, reported as `Created branch from /`). +- A switch that would mutate first probes the working tree; a dirty tree exits 1 with `working tree is dirty — commit or stash before switching` before anything is touched. + +### `--todo` — enter the topic's todo after the switch + +With `--todo` the external editor opens with the switched topic's `todo.md` after the switch — the same editor session as the create entry, prefilled with the existing todo. Saving overwrites the file as entered plus one trailing newline, without a commit; cancelling (no save, blank, or unchanged) leaves it untouched. + +- The flag needs an interactive terminal — without one it is a clean error before any resolution (exit 1). +- The chosen candidate must host a topic — `branch '' hosts no topic — switching creates nothing` (exit 1); switching never creates anything. +- Already sitting on the hosting branch still enters the todo — the idempotent switch carries the entry. + +The same resolution backs the switch half of `goga pipeline -t ` — there, an identifier nothing hosts creates fresh work instead of failing, and a sibling `--todo` flag opens the same entry (see [pipeline](../pipelines/cli.md#topic-switch)). + +## `goga topics delete` + +Deletes identified topics — the local branch, its origin twin, and the topic directory: + +```bash +goga topics delete feature-foo +# feature-foo -> Feature/Foo_Bar +# Delete 1 topic(s)? y +# Deleted 1 topic(s) of 2026: feature-foo + +goga topics delete feature-foo feature-bar --yes +# Deleted 2 topic(s) of 2026: feature-foo, feature-bar +``` + +Every IDENTIFIER resolves first — a branch name, a topic slug, or their prefix (the same tier order as `switch`), plus topic directories of the year no branch hosts: + +- An identifier nothing hosts, an ambiguous identifier, merged work, several branches hosting one topic, or the current branch hosting a target is a clean error (exit 1) and nothing is deleted — the resolution is all-or-nothing. +- A local branch and its `origin` twin collapse into one target; repeated identifiers naming one topic collapse too. A tracking ref of another remote is not a twin — the deletion push targets `origin` only. Two local branches normalizing into one slug (say `Feature/Foo` and `feature-foo`) never pick one of them by order — `several branches host topic '': — remove all but one of them before deleting`. +- Merged work is out of scope: a topic hosted by a branch that is not its own topic branch (the post-merge state) is a clean error naming the hosting branch — `topic '' is hosted by as merged work — remove it from the hosting branch's tree instead of deleting`. A topic directory no branch hosts stays deletable (directory only) — an unpublished topic (its todo not yet committed) reaches its disk directory by its exact name even though its branch carries no topic yet, and the bare branch itself stays. +- The resolved list prints one line per target — ` -> ` (or the twin, or `(directory only)`) — and one confirmation covers the whole list; a declined answer exits 0 with nothing deleted. `--yes`/`-y` skips the confirmation; without it a non-interactive terminal is a clean error naming the flag. +- The removal deletes each topic's local branch, its `origin` twin (a network push), and its topic directory — a directory a surviving branch still carries as merged work stays on disk with that branch's tree. The current branch hosting a target — by branch name or by slug — is a clean error asking to switch away first. +- A rejected remote deletion restores the failing target's local branch at its captured commit and surfaces git's reason as one clean error; targets removed before the failure stay removed. + +## Exit Codes + +| Code | Meaning | +|------|---------| +| `0` | Success — the board printed, the work created or published, the switch performed, the deletion done (including the idempotent switch and a declined deletion) | +| `1` | A clean domain error: an unresolvable or ambiguous identifier, no base for a creation, an occupied name, a missing todo under `--publish` or the no-switch creation, `--switch` together with `--publish`, a dirty working tree, merged work or the current branch hosting a deletion target, a failed publication or remote deletion, a git infrastructure failure, or a broken `goga_tool_*` package failing to import during status-scale assembly | +| `2` | A usage error (unknown option, missing argument) | + +## Notes + +- Every mutation is local except the two `origin` pushes — the `--publish` push and the delete push; no fetch ever happens. +- `goga history status` shows the same statuses scoped to the working copy of one year (see [history](../history/cli.md)). diff --git a/docs/features/topics/configuration.md b/docs/features/topics/configuration.md new file mode 100644 index 00000000..766ee3c8 --- /dev/null +++ b/docs/features/topics/configuration.md @@ -0,0 +1,18 @@ +# Topics — Configuration + +The topics domain reads one optional section of `.goga/config.yml` — `topics`, consumed by [`goga topics create`](cli.md). The section is read lazily: only when a value no CLI flag provided has to come from it. + +```yaml +topics: + base_ref: origin/main # default base of created topic branches + publish_commit: "feat: {slug} todo" # commit template of the published todo commit +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `topics.base_ref` | `string` | No | Base revision of a created topic branch — any revision string (branch, remote-tracking ref, tag, hash), stored verbatim with no resolvability check. Absent/YAML-null/empty/whitespace resolves to `None`; a non-string raises `ValueError`. Overridden by the `--base-ref` CLI option; the base resolves as `--base-ref` > `topics.base_ref` > the current HEAD under `--from-current` — a creation with none of the three exits 1 | +| `topics.publish_commit` | `string` | No | Commit message template of the published todo commit; the optional `{slug}` placeholder is replaced with the topic slug, and a template without it is used verbatim. Same normalization and typing rules as `base_ref`. Overridden by the `--commit`/`-c` CLI option (publication-only); the built-in default is `goga: create topic {slug}` | + +When `topics` is absent, the configuration is "everything unset". Unknown keys inside the mapping are ignored — the same stance as `lint` and `codemanifest`. A non-mapping `topics` value, or a non-string field, raises `ValueError` at load time (see [Configuration — validation errors](../../configuration/project.md#validation-errors)). + +The general file location, loading rules, and the shared example live in [Project Configuration](../../configuration/project.md). diff --git a/docs/features/topics/hooks.md b/docs/features/topics/hooks.md new file mode 100644 index 00000000..ab068060 --- /dev/null +++ b/docs/features/topics/hooks.md @@ -0,0 +1,5 @@ +# Topics — Hooks + +The topics domain exposes **no hook actions** for tool packages today. + +Topic identity, addressing, and statuses belong to the [History](../history/index.md) domain — the one hook action of that surface (the status-scale registration) is declared there: see [History — Hooks](../history/hooks.md). The platform mechanism behind every action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/topics/index.md b/docs/features/topics/index.md new file mode 100644 index 00000000..78b67097 --- /dev/null +++ b/docs/features/topics/index.md @@ -0,0 +1,25 @@ +# Topics + +Organize work as **topics** — one directory per piece of work under `.goga/history///`, each usually living on its own git branch. + +The topics domain is the work-tracker view of the history tree: it answers *what is being worked on, where it lives, and how to get onto it*. Which tasks it solves: + +- **See the work** — the board (`goga topics board`) is the cross-branch inventory of one year: every topic with its hosting branch, its statuses, and (with `--info`) its todo summary. It reads branch trees without checkout, so the board sees committed work on every branch — and the working copy of the current one. +- **Start work** — creation (`goga topics create`) plants a branch with the topic's first artifact (the committed `todo.md`) at an explicit base, or publishes it to `origin` in one step. By default you stay on your branch — the quarantine commit never touches your working copy. +- **Enter the intent** — the todo is the multi-line statement of the work, entered on the command line (`--todo`) or in the external editor; it feeds the `todo` status and the board's `--info` column. +- **Resume work** — switching (`goga topics switch`) resolves a branch name, a topic slug, or their prefix onto the hosting branch; `goga pipeline -t ` runs the same resolution before a pipeline launch (see [Pipelines](../pipelines/cli.md#topic-switch)). +- **Finish work** — deletion (`goga topics delete`) removes a topic's local branch, its `origin` twin, and its directory in one confirmed step. + +## Model + +- A topic is identified by its **slug** — the normalized name (lowercase, non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed: `Feature/Foo_Bar` → `feature-foo-bar`). +- The topic directory is `.goga/history///`; its artifacts (`todo.md`, `prd.md`, …) carry the topic's statuses (see [History](../history/index.md)). +- Every mutation is local except the two `origin` pushes — the `--publish` push and the delete push; no fetch ever happens. +- Domain errors are clean one-line errors (exit 1, no traceback). + +## In this directory + +- [CLI](cli.md) — the full `goga topics` command reference +- [Configuration](configuration.md) — the `topics:` section of `.goga/config.yml` +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.topics` package facade diff --git a/docs/features/upgrade/api.md b/docs/features/upgrade/api.md new file mode 100644 index 00000000..cfea2091 --- /dev/null +++ b/docs/features/upgrade/api.md @@ -0,0 +1,24 @@ +# Upgrade — API + +The facade of the domain package **`goga.commands.upgrade`** — the pip upgrade of goga (and tools) with the agent re-sync. + +The signature below is the CODEMANIFEST contract of the cell. + +```python +upgrade(ctx: click.Context, sudo: bool, user: str | None, tools: bool, + patch: bool, minor: bool) -> int +``` + +Upgrade the goga package in the running interpreter's pip and re-sync every agent recorded in the home `connect.yml`. `sudo` — run pip with `sudo --preserve-env=HOME` (a system-Python install); `user` — re-sync another user's goga installation (`SUDO_USER` resolution happens here); `tools` — additionally upgrade every installed `goga_tool_*` package; `patch` / `minor` — constrain the version line (`--patch`: the latest patch of the installed minor; `--minor`: the latest release of the installed major; neither: the latest release). Returns the exit code. + +## Example + +```python +import click +from goga.commands.upgrade import upgrade + +@click.command() +def cmd(): + raise SystemExit(upgrade(click.get_current_context(), sudo=False, user=None, + tools=True, patch=True, minor=False)) +``` diff --git a/docs/cli/upgrade.md b/docs/features/upgrade/cli.md similarity index 91% rename from docs/cli/upgrade.md rename to docs/features/upgrade/cli.md index e264c09f..adfa75bc 100644 --- a/docs/cli/upgrade.md +++ b/docs/features/upgrade/cli.md @@ -12,7 +12,7 @@ goga upgrade [--sudo] [--user NAME] [--tools] [--patch | --minor] `goga upgrade` runs `pip install goga -U` on the current Python interpreter, then re-syncs every agent recorded in `~/.goga/connect.yml` using each agent's persisted `force_overwrite` setting. -This is the supported way to move to a new goga release: because `goga connect` installs assets centrally into `~/.goga/` and symlinks them into each agent directory (see [`goga connect`](connect.md)), an upgrade must re-run that install and refresh the symlinks. `goga upgrade` does both in one command, driven by the registry that `goga connect` wrote when you first connected your agents. +This is the supported way to move to a new goga release: because `goga connect` installs assets centrally into `~/.goga/` and symlinks them into each agent directory (see [`goga connect`](../connect/cli.md)), an upgrade must re-run that install and refresh the symlinks. `goga upgrade` does both in one command, driven by the registry that `goga connect` wrote when you first connected your agents. ## Options @@ -48,13 +48,13 @@ By default `goga upgrade` installs the latest released goga (`pip install goga - - The line base is read from the current interpreter's `importlib.metadata` — not from the container image tag and not from the working directory. Whatever goga the invoked interpreter has installed defines the line. - Rich version bases are truncated to their release segments: an installed `1.2.1.dev0` (likewise `1.2.0rc1`, `1.2.0.post1`, `1.2.0+local`) still resolves to the 1.2 line — `goga~=1.2.0` under `--patch`, `goga~=1.0` under `--minor`. -- `--patch` requires the installed version to carry a minor segment: an installed major-only version (e.g. `2`) exits 1 with `cannot resolve the version line` — no `.0` minor is invented. `--minor` works from a major-only base (`2` → `goga~=2.0`). +- `--patch` requires the installed version to carry a minor segment: an installed major-only version (e.g. `2`) exits 1 with `cannot determine version line from ` — no `.0` minor is invented. `--minor` works from a major-only base (`2` → `goga~=2.0`). - Both flags are validated before pip runs: combining `--patch --minor` exits 1 with a mutual-exclusion error, and an installed version that cannot be read in this interpreter exits 1 — there is no fallback to latest. - With `--tools`, the constraint applies only to the goga identifier; discovered `goga_tool_*` packages are upgraded unconstrained in the same pip invocation. ## The connect.yml registry -`~/.goga/connect.yml` is written by `goga connect` and read by `goga install`, `goga upgrade`, and [`goga uninstall`](uninstall.md) (via the shared re-sync routine): +`~/.goga/connect.yml` is written by `goga connect` and read by `goga install`, `goga upgrade`, and [`goga uninstall`](../install/uninstall.md) (via the shared re-sync routine): ```yaml agents: diff --git a/docs/features/upgrade/configuration.md b/docs/features/upgrade/configuration.md new file mode 100644 index 00000000..bfaa3497 --- /dev/null +++ b/docs/features/upgrade/configuration.md @@ -0,0 +1,5 @@ +# Upgrade — Configuration + +The upgrade domain reads **no section of `.goga/config.yml`** — the version line is derived from the installed package, and the agents to re-sync come from the home state (`~/.goga/connect.yml`, see [Configuration — Home](../../configuration/home.md)). + +The general configuration model of the product is covered in [Configuration](../../configuration/index.md). diff --git a/docs/features/upgrade/hooks.md b/docs/features/upgrade/hooks.md new file mode 100644 index 00000000..6ef3de3f --- /dev/null +++ b/docs/features/upgrade/hooks.md @@ -0,0 +1,5 @@ +# Upgrade — Hooks + +The upgrade domain exposes **no hook actions** for tool packages today. + +Its reach into the tool ecosystem is direct pip: `--tools` upgrades every installed `goga_tool_*` package and the run re-syncs the connected agents (see [Overview](index.md)). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/upgrade/index.md b/docs/features/upgrade/index.md new file mode 100644 index 00000000..bc9688c7 --- /dev/null +++ b/docs/features/upgrade/index.md @@ -0,0 +1,17 @@ +# Upgrade + +Upgrade the goga package — and optionally the installed tool packages — then re-sync every connected agent. + +The upgrade domain keeps the whole installation in step. Which tasks it solves: + +- **Stay current** — `goga upgrade` upgrades goga in the running interpreter's pip; `--tools` additionally upgrades every installed `goga_tool_*` package. +- **Stay on a line** — `--patch` upgrades to the latest patch of the installed minor line; `--minor` to the latest release of the installed major line; the default takes the latest release. +- **Keep agents in step** — after a successful pip, every agent recorded in `~/.goga/connect.yml` is re-synced (through the [Connect](../connect/index.md) domain), so the refreshed skills and commands appear in each agent immediately. +- **System installs** — `--sudo` runs the pip with `sudo --preserve-env=HOME`; `--user NAME` re-syncs another user's goga installation. + +## In this directory + +- [CLI](cli.md) — the full `goga upgrade` command reference +- [Configuration](configuration.md) — the domain reads no configuration section +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.commands.upgrade` package facade diff --git a/docs/features/usages/api.md b/docs/features/usages/api.md new file mode 100644 index 00000000..caef6a54 --- /dev/null +++ b/docs/features/usages/api.md @@ -0,0 +1,47 @@ +# Usages — API + +The facade of the domain package **`goga.usages`** — a re-export facade embedding `sync` and `status` (with the status result types) so consumers import a single entry point. It owns no behavior — the logic lives in the child cells `goga.usages.sync` and `goga.usages.status`. + +The signatures below are the CODEMANIFEST contract of the cells. + +## Sync + +```python +sync(force: bool = False, group: str | None = None, dep: str | None = None) -> int +clean_usages_dir(usages_root: Path) -> int +clone_repository(git: str, ref: str | None) -> Path +deploy_usages(source_repo: Path, target_dir: Path, root: str | None = None) -> int +``` + +`sync` materializes and refreshes the usage files of every declared dependency (or one `group`/`dep` slice) into `.goga/usages/` — clone each remote at its `ref`, discover `.usages` folders under `root`, copy the files; `force` overwrites local modifications. Returns the exit code. The helpers behind it: the idempotent wipe of the target tree, the clone, and the deployment of one dependency's files. + +## Status + +```python +status(group: str | None = None, dep: str | None = None) -> UsageStatusReport +hash_tree(root: Path) -> dict[str, str] +compute_dep_status(group: str, dep: str, depcfg: DepConfig, target: Path) -> DepStatus +``` + +`status` checks the synchronized files against each dependency's current remote state — clone at the remote tip, hash both trees, compare — and returns the report. The helpers: the tree hashing and one dependency's comparison. + +## The result types + +```python +UsageStatusReport(deps: list[DepStatus]) +DepStatus(group: str, dep: str, state: UsageState, entries: list[EntryStatus], error: str | None = None) +EntryStatus(path: str, kind: EntryKind, change: EntryChange) +``` + +One `DepStatus` per dependency: its `state` (`UsageState`), its per-entry statuses, and an `error` when the remote could not be reached. Each `EntryStatus` carries the file's `path`, its `kind` (file/dir), and the `change` class. + +## Example + +```python +from goga.usages import status, sync + +sync() # materialize every declared dependency +report = status() +for dep in report.deps: + print(dep.group, dep.dep, dep.state) +``` diff --git a/docs/cli/usages.md b/docs/features/usages/cli.md similarity index 100% rename from docs/cli/usages.md rename to docs/features/usages/cli.md diff --git a/docs/features/usages/configuration.md b/docs/features/usages/configuration.md new file mode 100644 index 00000000..babc696e --- /dev/null +++ b/docs/features/usages/configuration.md @@ -0,0 +1,26 @@ +# Usages — Configuration + +The usages domain reads one optional section of `.goga/config.yml` — `usages`, the git dependencies whose cell-level `.usages/` files are synced by [`goga usages sync`](cli.md) and checked by [`goga usages status`](cli.md). + +```yaml +usages: + cooks: # group — a subdirectory of .goga/usages/ + goga: # dependency — a subdirectory of the group + git: https://github.com/qarium/goga.git + ref: 1.3.x # optional — branch, tag, or commit + root: docs # optional — subpath to discover .usages from +``` + +A two-level mapping: `` → `` → fields. + +| Field | Type | Required | Description | +|---|---|---|---| +| `usages.` | mapping | Yes when `usages` present | Group bucket. The key becomes a top-level subdirectory of `.goga/usages/`. Validated as a path segment (no empty / `.` / `..` / `/` / `\`) | +| `usages..` | mapping | Yes when `` present | Dependency entry. The key becomes a subdirectory under the group. Same path-segment validation | +| `usages...git` | `string` | Yes | Git URL of the source repository. Must be non-empty | +| `usages...ref` | `string` | No | Git ref — branch, tag, or commit. `None` (omitted) clones the default branch | +| `usages...root` | `string` | No | Subpath inside the clone to discover `.usages` folders from. Absent (or an empty string) → clone root. Must be relative; no `..` or absolute paths | + +`usages` defaults to `None` (absent), which makes `goga usages sync` a no-op (exit 0); an empty mapping is `{}`. + +The general file location, loading rules, and the shared example live in [Project Configuration](../../configuration/project.md). diff --git a/docs/features/usages/hooks.md b/docs/features/usages/hooks.md new file mode 100644 index 00000000..cbba3306 --- /dev/null +++ b/docs/features/usages/hooks.md @@ -0,0 +1,5 @@ +# Usages — Hooks + +The usages domain exposes **no hook actions** for tool packages today. + +The sync is driven purely by the configuration declarations (see [Configuration](configuration.md)). The platform mechanism behind every hook action is covered in [Hooks](../hooks/index.md). diff --git a/docs/features/usages/index.md b/docs/features/usages/index.md new file mode 100644 index 00000000..d610277f --- /dev/null +++ b/docs/features/usages/index.md @@ -0,0 +1,18 @@ +# Usages + +Sync cell-level usages from declared git dependencies — and check them for drift. + +The usages domain keeps imported practices current. Which tasks it solves: + +- **Declare dependencies** — the `usages:` section of `.goga/config.yml` maps `` → `` → `{git, ref, root}`: the repositories whose cell-level `.usages/*.md` practices your project consumes. +- **Materialize** — `goga usages sync` clones each dependency's remote and copies its usage files into your project's `.goga/usages///` tree — the practices your CODEMANIFEST files reference through `Usages` paths. +- **Detect drift** — `goga usages status` hashes the synchronized files against each dependency's current remote state and reports per-entry status — up to date, behind, or locally modified — without modifying anything. + +Together with the `Imports` mechanism of the DSL (see [Cell — Usages](../../cell/usages.md)), this makes project know-how travel between repositories with the code. + +## In this directory + +- [CLI](cli.md) — the full `goga usages` command reference +- [Configuration](configuration.md) — the `usages:` section of `.goga/config.yml` +- [Hooks](hooks.md) — hook points for tool packages +- [API](api.md) — the `goga.usages` package facade diff --git a/docs/getting-started.md b/docs/getting-started.md index c8cff640..36c97a29 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -27,7 +27,7 @@ Move to a new goga release and re-sync every connected agent in one step — no goga upgrade ``` -To stay within your current version line, add `--patch` (latest patch of the installed minor line) or `--minor` (latest release of the installed major line). See [`goga upgrade`](cli/upgrade.md) for details. +To stay within your current version line, add `--patch` (latest patch of the installed minor line) or `--minor` (latest release of the installed major line). See [`goga upgrade`](features/upgrade/cli.md) for details. ## Initialize a project @@ -43,13 +43,13 @@ The wizard will prompt you for: 2. **Convention** -- Optionally download language-specific conventions from the goga-lang-conventions repository 3. **Codemanifest usages** -- Optional named practices (key-value pairs) for your project 4. **Codemanifest annotations** -- Optional free-text instructions for AI agents -5. **Agent** -- Confirm-gated (defaults to No). Decline to skip the build agent, or accept and choose `claude` or `codex` +5. **Agent** -- Confirm-gated (defaults to No). Decline to skip the build agent, or accept and choose an agent — `claude`, `codex`, `cursor`, `opencode`, or `qwen` 6. **Custom Dockerfile** -- Optionally create a custom Dockerfile (suggested path `.goga/Dockerfile`). This decision drives the next step: image semantics differ between the two branches. 7. **Docker image** (depends on step 6): - **If you create a Dockerfile**, the image is **built from it**, so you provide two values: the **base image** for the `FROM` line (chosen from the language-specific list), and a **built image name/tag** (what `goga build` tags with `docker build -t`). The built image name defaults to `:latest`, where `` is derived from your git `origin` remote URL; when no git remote is available, no default is offered and the name is required. - **If you skip the Dockerfile**, you pick a **pre-built image to pull** from the language-specific list (or enter a custom one). 8. **Environment variables** -- Set agent-specific env vars (e.g., `ANTHROPIC_API_KEY`) -9. **Pipeline agent** -- Confirm-gated (defaults to No). Decline to skip the pipeline agent, or accept and choose `claude` or `codex`. Does not inherit the build agent from step 5 — the two are collected independently +9. **Pipeline agent** -- Confirm-gated (defaults to No). Decline to skip the pipeline agent, or accept and choose an agent — `claude`, `codex`, `cursor`, `opencode`, or `qwen`. Does not inherit the build agent from step 5 — the two are collected independently 10. **Pipeline environment variables** -- Set env vars for the pipeline container (e.g., `ANTHROPIC_API_KEY`) ### What `goga init` creates @@ -83,7 +83,7 @@ goga init --upgrade # re-apply at the recorded ref goga init --upgrade --ref v2.0 # migrate to a specific ref ``` -`` and `--upgrade` are mutually exclusive; `--ref` requires one of them. See [`goga init`](cli/init.md) for details. +`` and `--upgrade` are mutually exclusive; `--ref` requires one of them. See [`goga init`](features/init/cli.md) for details. ## Develop your first feature @@ -127,7 +127,7 @@ goga pipeline review # scoped review of code, contracts, docs, then lint/for goga pipeline sync # sync specifications and tests with the implementation ``` -See [Pipelines](pipelines/index.md) for the full functional model, and [Shipped Pipelines](pipelines/shipped.md) for the per-pipeline walkthrough. +See [Pipelines](features/pipelines/index.md) for the full functional model, and [Shipped Pipelines](features/pipelines/shipped.md) for the per-pipeline walkthrough. ### Manual cycle @@ -137,9 +137,9 @@ If you want explicit control over each step instead of running the whole cycle a /goga:propose ``` -> The slash-command form `/goga:` works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](cli/connect.md)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-propose` (Codex uses the `$` prefix — `$goga-propose`). +> The slash-command form `/goga:` works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](features/connect/cli.md)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-propose` (Codex uses the `$` prefix — `$goga-propose`). -The agent walks you through an interactive dialogue, then produces `docs/tasks/.md`. From there, each subsequent command takes the previous artifact as input and produces the next one. See the [Workflow](workflow/index.md) section for the full algorithm of each step in both workrounds — refinement and development — including shortcut paths for smaller changes. +The agent walks you through an interactive dialogue, then produces `.goga/history///task.md`. From there, each subsequent command takes the previous artifact as input and produces the next one. See the [Workflow](workflow/index.md) section for the full algorithm of each step in both workrounds — refinement and development — including shortcut paths for smaller changes. ## View diff --git a/docs/index.md b/docs/index.md index b6478a02..73473dbd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,7 +55,7 @@ Upgrade goga later and re-sync every connected agent — no need to call pip dir goga upgrade ``` -Line-constrained upgrades (`--patch` / `--minor`) and the other options are covered in [`goga upgrade`](cli/upgrade.md). +Line-constrained upgrades (`--patch` / `--minor`) and the other options are covered in [`goga upgrade`](features/upgrade/cli.md). Initialize a project — the interactive wizard sets up `.goga/config.yml`, language conventions, and (optionally) a `Dockerfile`: @@ -100,7 +100,7 @@ goga pipeline review # scoped review of code, contracts, docs, then lint/for goga pipeline sync # sync specifications and tests with the implementation ``` -A pipeline-file answers **what** the pipeline does. An optional [workflow](pipelines/workflows.md) file answers **how the same pipeline should behave in this project** — per-stage agent, extra prompt context, loop expansion, stage skipping — without forking the base file. See [Pipelines](pipelines/index.md) for the full functional model. +A pipeline-file answers **what** the pipeline does. An optional [workflow](features/pipelines/workflows.md) file answers **how the same pipeline should behave in this project** — per-stage agent, extra prompt context, loop expansion, stage skipping — without forking the base file. See [Pipelines](features/pipelines/index.md) for the full functional model. ### Drive the cycle by hand @@ -110,11 +110,11 @@ If you want explicit control over each step instead of running the whole cycle a /goga:propose ``` -> The slash-command form `/goga:` works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](cli/connect.md)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-propose` (Codex uses the `$` prefix — `$goga-propose`). Each subsequent command takes the previous artifact as input and produces the next one. See [Workflow](workflow/index.md) for the two workrounds — refinement and development — and the entry depths each supports. +> The slash-command form `/goga:` works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](features/connect/cli.md)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-propose` (Codex uses the `$` prefix — `$goga-propose`). Each subsequent command takes the previous artifact as input and produces the next one. See [Workflow](workflow/index.md) for the two workrounds — refinement and development — and the entry depths each supports. ## Next steps -- [Pipelines](pipelines/index.md) — Run the agent-driven cycle automatically with `goga pipeline` +- [Pipelines](features/pipelines/index.md) — Run the agent-driven cycle automatically with `goga pipeline` - [Getting Started](getting-started.md) — Initialize your first goga project - [Workflow](workflow/index.md) — The agent-driven feature development cycle - [Cell](cell/index.md) — Cell structure, usages, and CODEMANIFEST DSL reference diff --git a/docs/workflow/apply.md b/docs/workflow/apply.md index 3ef65c84..4cb7617c 100644 --- a/docs/workflow/apply.md +++ b/docs/workflow/apply.md @@ -1,6 +1,6 @@ # Apply -Materialize an architecture plan into the cells file structure. Reads `docs/arch/.md` and produces actual `CODEMANIFEST` files and `.usages/` directories on disk. +Materialize an architecture plan into the cells file structure. Reads `.goga/history///arch.md` and produces actual `CODEMANIFEST` files and `.usages/` directories on disk. ## Synopsis @@ -8,6 +8,8 @@ Materialize an architecture plan into the cells file structure. Reads `docs/arch /goga:apply ``` +The architecture plan comes from the current git branch; the argument is an optional path to the plan file. + Examples use the slash-command form `/goga:`, which works in agents that consume the goga command bundle (`claude`, `opencode`, `qwen`). Codex and cursor do not register commands — invoke the skill directly: `goga-apply` (Codex: `$goga-apply`). See [Workflow](index.md). ## Output artifacts @@ -19,6 +21,14 @@ For each cell in the plan: The skill does **not** write implementation code — only the contract skeleton. +## Pre-flight check + +```bash +goga --help +``` + +If `goga` is unavailable, the skill halts. + ## Algorithm ### Phase 1. Load DSL specification and principles @@ -33,7 +43,7 @@ The skill does **not** write implementation code — only the contract skeleton. | Step | Action | |---|---| -| 1. Locate the plan file | Use argument as path or `docs/arch/.md`; if no argument, list `docs/arch/` via `AskUserQuestion`; halt if file missing. | +| 1. Locate the architecture file | See [Resolving the architecture file](#resolving-the-architecture-file); an argument containing a path wins. | | 2. Parse the plan structure | Extract implementation order, artifacts per cell, dependency map, verification checklist. | | 3. Classify cells | Mark each as **new** (directory does not exist) or **modification** (directory exists; read current CODEMANIFEST to compute diff). | @@ -74,28 +84,15 @@ Process cells **strictly in plan order** (leaves → root). For each cell: 2. **Dependency map** — confirmed inter-cell connections. 3. **Validation status** — linter and schema results. -## Resolving the topic +## Resolving the architecture file -If `` is omitted: - -1. Scan `docs/arch/`. -2. **Single file** — use automatically. -3. **Multiple files** — ask the user via `AskUserQuestion`. -4. **Empty or missing** — halt and report. - -## Pre-flight check - -```bash -goga --help -``` - -If `goga` is unavailable, the skill halts. +The architecture plan is read from the path printed by `goga history path -f arch.md` (the topic of the current git branch). An argument containing a path wins over the printed path. If the file does not exist — halt and ask the user to run `brainstorm` first. ## Inputs and outputs | | | |---|---| -| **Input** | `docs/arch/.md` | +| **Input** | `.goga/history///arch.md` | | **Output** | Cells on disk: `CODEMANIFEST` files and `.usages/` directories | ## What happens next diff --git a/docs/workflow/brainstorm.md b/docs/workflow/brainstorm.md index ae7cf0ad..2069923c 100644 --- a/docs/workflow/brainstorm.md +++ b/docs/workflow/brainstorm.md @@ -8,11 +8,13 @@ Design the cells architecture for a task through a structured, interactive pipel /goga:brainstorm ``` +The topic follows the current git branch; the argument is an optional free-form description or a path to `task.md`. + Examples use the slash-command form `/goga:`, which works in agents that consume the goga command bundle (`claude`, `opencode`, `qwen`). Codex and cursor do not register commands — invoke the skill directly: `goga-brainstorm` (Codex: `$goga-brainstorm`). See [Workflow](index.md). ## Output artifact -`docs/arch/.md` — an architecture plan with four mandatory sections: +`.goga/history///arch.md` — an architecture plan with four mandatory sections: 1. Implementation order (leaves → root, with rationale per cell) 2. Artifacts per cell (CODEMANIFEST contents + `.usages/` files) @@ -69,7 +71,7 @@ Context flows between phases through these named reports — the orchestrator ne ### Phase 1. Intake -Accept the description (brief, detailed, or a path to `docs/tasks/.md`). When a task file is supplied, its sections feed the pipeline: +Accept the description (brief, detailed, or a path to `.goga/history///task.md`). When a task file is supplied, its sections feed the pipeline: - Current state → context for primary analysis - Description and boundaries → design basis @@ -130,7 +132,7 @@ Assemble the CODEMANIFEST for each cell (Header → Body → Footer) and propose ### Phase 9. Plan Assembly -Write the architecture plan to `docs/arch/.md` with the four mandatory sections. Each CODEMANIFEST must be syntactically correct; modifications are described as diffs; file names must match the project structure. +Write the architecture plan to `.goga/history///arch.md` with the four mandatory sections. Each CODEMANIFEST must be syntactically correct; modifications are described as diffs; file names must match the project structure. **WAIT:** present the plan and obtain confirmation. **STOP if:** plan incomplete. @@ -154,10 +156,10 @@ Applies to every interactive phase: ## Inputs and outputs -| | | -|------------|--------------------------------------------------| -| **Input** | `docs/tasks/.md` (approved task) | -| **Output** | `docs/arch/.md` — cells architecture plan | +| | | +|------------|----------------------------------------------------------------------------------| +| **Input** | `.goga/history///task.md` (approved task) or a free-form description| +| **Output** | `.goga/history///arch.md` — cells architecture plan | ## What happens next diff --git a/docs/workflow/build.md b/docs/workflow/build.md index 9e9586d9..2ff34294 100644 --- a/docs/workflow/build.md +++ b/docs/workflow/build.md @@ -19,19 +19,19 @@ Implemented code in the project tree, produced by the ralph-loop executing each | Step | Stage | Action | |---|---|---| | 1. Docker check | host | Verify Docker is installed and accessible. Halt on failure. | -| 2. Config loading | host | Read `.goga/config.yml` for `image`, `dockerfile`, `build.task_executor.agent`, env, and timeouts. Refuse to run when the `build` section is absent or the top-level `image` is unset. | +| 2. Config loading | host | Read `.goga/config.yml` for `image`, `dockerfile`, `build.task_executor.agent`, env, and timeouts. Refuse to run when the `build` section is absent, the top-level `image` is unset, or `build.task_executor.agent` is unset. | | 3. Home config + git identity layering | host | Load the optional machine-wide home config (`~/.goga/config.yml`). `home.env` is the BASE (lowest-priority) layer of the container env-file; `home.docker.run` is appended to every `docker run`; `home.docker.build` is forwarded to image build. Layer in git identity env (`GIT_AUTHOR_NAME/EMAIL`, `GIT_COMMITTER_NAME/EMAIL`) — tolerate absent git config. | | 4. Project preconditions | host → in-container | Resolve proxy (CLI `--proxy` wins over `config.build.proxy`); resolve hosts (CLI `--add-host` merges on top of `config.build.hosts`, CLI wins on conflict); when `--skip-manifest-check` is not set, scan `git status` for uncommitted `CODEMANIFEST` files and reject with exit 1 if any are found. | | 5. Agent preconditions | host → in-container | The in-container entrypoint resolves the agent wrapper via `resolve_wrapper_path(config.build.task_executor.agent)` and writes `.ralphex/config` per pass with `claude_command` set to the pass's wrapper path (`/home/goga/bin/-as-claude.sh`), `claude_args` defaults when missing, `codex_enabled` from `BuildConfig`, `preserve_anthropic_api_key: true`, and `move_plan_on_completion: false` (goga owns the plan relocation itself). A review executor whose agent differs from the task executor, or that declares a non-empty `env`, combined with an active worktree is rejected on the host with exit 1 before any container launch (skip-independent — `--skip-review` does not bypass it). | | 6. Defaults sync | in-container | Fully rewrite `.ralphex/prompts/` and `.ralphex/agents/` (stale files removed) from the configured `build.prompts_dir`/`build.agents_dir`, or from the vendored ralph-loop defaults shipped with goga (`goga/assets/ralphex/`). When `build.review_executor.roles` is a non-empty list, both review prompts are filtered to the selected roles and their counters adapted; with the full role set or no roles the files are byte-identical to the source. Custom directories are copied as-is, without filtering. | -| 7. ralph-loop option resolution | in-container | Resolve ralph-loop options with precedence CLI options > `BuildConfig` > omit — `worktree`, `skip_finalize`, `session_timeout`, `idle_timeout`, `wait`, `max_iterations`, `review_patience`. The resolved options are forwarded to `run_ralphex`; the launcher does not resolve precedence itself. | +| 7. ralph-loop option resolution | in-container | Two zones. Universal options resolve once in `_resolve_options` with precedence CLI options > `BuildConfig` > omit — `worktree`, `skip_finalize`, `session_timeout`, `idle_timeout`, `wait`, `max_iterations` — and join every pass. Review-scoped options — `base_ref`, `patience` — resolve in `resolve_review_options` with precedence CLI options > `ReviewExecutorConfig` > omit and join review-carrying passes only. The resolved options are forwarded to `run_ralphex`; the launcher does not resolve precedence itself. | | 8. Image refresh (optional, `--update`/`-u`) | host | When set, refresh the image via `docker_update`: build when a top-level `dockerfile` is declared in `.goga/config.yml` (fatal on failure — exit 1), otherwise `docker pull ` (warning on failure, non-fatal — the build proceeds with the locally available image). Off by default. | | 8b. First-run safety net | host | Runs unconditionally at launch entry via `docker_build_if_not_exist`: when `config.image` is absent locally AND `config.dockerfile` is declared, build it once before launch (fatal on failure — surfaces as a `ClickException`, launch skipped). No-op when the image is present or no Dockerfile is set. This closes the corner case where a project Dockerfile is declared but the image was never built and `--update` is not passed. | | 9. Persistent ralph-loop runtime isolation | host | Bind-mount a centralized host directory at `/workspace/.ralphex` inside the container so ralph-loop state never lands in the user's project directory. The host directory is `~/.goga/runtime/builds///` and survives across runs of the same project on the same branch (useful for resuming interrupted builds). Pass `-c`/`--clean` to wipe it before launch. Any `.ralphex/` Docker creates in the project directory is removed unconditionally on every exit path, including crash/SIGKILL. | | 10. Docker launch | host | Launch the ralph-loop inside the configured Docker image using the in-container build entry point. A SIGTERM/SIGINT handler is installed before the secret env-file is written so a signal during setup unwinds to `finally` and unlinks the file. | | 10a. Pre-launch version check | host | Inside the Docker launch, before the work container starts: one short-lived probe container (`docker run --rm --entrypoint python3 -c "from importlib.metadata import version; print(version('goga'))"`, output captured) reports the image's goga version, compared with the host version at the (major, minor) level. A mismatch, a probe that cannot answer, or an undeterminable host version → one stderr message + exit 1, container not started. An image reporting `0.0.0` (locally built, no stamped version) → stderr warning, launch continues. Set `GOGA_SKIP_VERSION_CHECK=1` to skip the probe and the comparison entirely. | | 11. Docker guard | in-container | The in-container entrypoint refuses to proceed outside the goga Docker image as its very first action. | -| 12. ralph-loop launch via `run_ralphex` | in-container | `build()` resolves the review options (`--skip-review`/`--no-skip-review` CLI pair > `build.review_executor.skip` > full cycle), validates them when the review phase will run, then delegates each pass to `run_ralphex` (goga/ralphex) with `plan`, the pass's options, `dry_run`, and — for the review pass of a two-pass run — the review env layer. One pass by default; a skipped review runs a single tasks-only pass (`ralphex --tasks-only`); a review executor agent differing from the task agent, or a non-empty `build.review_executor.env` (with an agent set), runs two passes — tasks with the task wrapper, then the review pass (`ralphex --review`) with the review wrapper and the review env overlaid on the container environment for that subprocess only (a failed first pass skips the second). `run_ralphex` maps options to ralphex CLI flags, verifies the `ralphex` binary is on `$PATH`, and propagates the subprocess exit code. A binary missing from `$PATH` — including when the env layer's `PATH` override hides it from the exec — or a launch rejected before the exec (an env-layer key that is not a legal environment variable name, an oversized layer, or a `PATH` override resolving a non-executable or non-directory ralphex binary) returns 1 with a clean one-line message on stderr — never a traceback, and never the env layer's values. The build environment is inherited from the docker env-file (`os.environ`) with an optional caller-supplied overlay for the review subprocess — never reconstructed from a config object. | +| 12. ralph-loop launch via `run_ralphex` | in-container | `build()` resolves the review options (`--skip-review`/`--no-skip-review` CLI pair > `build.review_executor.skip` > full cycle), validates them when the review phase will run, then delegates each pass to `run_ralphex` (goga/ralphex) with `plan`, the pass's options, `dry_run`, and — for the review pass of a two-pass run — the review env layer. One pass by default; a skipped review runs a single tasks-only pass (`ralphex --tasks-only`); a review executor agent differing from the task agent, or a non-empty `build.review_executor.env` (with an agent set), runs two passes — tasks with the task wrapper, then the review pass (`ralphex --review`) with the review wrapper and the review env overlaid on the container environment for that subprocess only (a failed first pass skips the second). The review-carrying pass — the single full-cycle pass or the review pass of a two-pass run — also carries the review-scoped options resolved in step 7 (`base_ref` → ralphex `--base-ref`, `patience` → ralphex `--review-patience`); a tasks-only pass carries the universal options only. `run_ralphex` maps options to ralphex CLI flags, verifies the `ralphex` binary is on `$PATH`, and propagates the subprocess exit code. A binary missing from `$PATH` — including when the env layer's `PATH` override hides it from the exec — or a launch rejected before the exec (an env-layer key that is not a legal environment variable name, an oversized layer, or a `PATH` override resolving a non-executable or non-directory ralphex binary) returns 1 with a clean one-line message on stderr — never a traceback, and never the env layer's values. The build environment is inherited from the docker env-file (`os.environ`) with an optional caller-supplied overlay for the review subprocess — never reconstructed from a config object. | | 13. Plan relocation | in-container | After a successful final pass the plan file moves to `/completed/` (atomic replace, idempotent by name). A failed run or a dry run leaves the plan in place for the ralph-loop to resume. | Inside the container, the ralph-loop executes the plan: one task per iteration, following the ralphex execution protocol (declaration → contract tests → implementation → interface verification → logic tests → debugging → contract re-verification → lint → completion → review → approval → next task). @@ -39,14 +39,14 @@ Inside the container, the ralph-loop executes the plan: one task per iteration, ## When to use - After `plan` and `review(plan)`, when the plan is approved. -- Whenever an execution plan exists in `docs/plans/` and is ready to be executed. +- Whenever an execution plan exists under `.goga/history/` and is ready to be executed. ## Inputs and outputs | | | |---|---| -| **Input** | `docs/plans/.md` — the execution plan | -| **Output** | Implemented code in the project tree; after a successful run the plan itself moves to `docs/plans/completed/.md` | +| **Input** | `.goga/history///plan.md` — the execution plan | +| **Output** | Implemented code in the project tree; after a successful run the plan itself moves to `.goga/history///completed/plan.md` | ## Options @@ -62,6 +62,7 @@ Inside the container, the ralph-loop executes the plan: one task per iteration, | `--wait` | string | config | Wait time before starting | | `--max-iterations` | int | config | Maximum number of build iterations | | `--review-patience` | int | config | Review patience count | +| `--base-ref` | string | config | Review diff base (branch name or commit hash); overrides `build.review_executor.base_ref` | | `-e`, `--env` | string | -- | Additional environment variable (`KEY=VALUE`, repeatable). Forwarded into the container env-file. | | `--proxy` | string | config | HTTP/HTTPS proxy URL; overrides `build.proxy` in `.goga/config.yml`. When set, adds `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY=localhost,127.0.0.1` to the container env-file. | | `--add-host` | string (repeatable) | -- | Add a `docker run --add-host HOST:IP` entry. Merges on top of `build.hosts` from config; CLI wins on host-key conflict. | @@ -70,26 +71,28 @@ Inside the container, the ralph-loop executes the plan: one task per iteration, Timeout and iteration options fall back to `.goga/config.yml` when not provided on the command line. +`--review-patience` and `--base-ref` are review-scoped: they resolve with precedence CLI > `build.review_executor.*` in `.goga/config.yml` > omit, and they apply to review-carrying passes only — the single full-cycle pass, or the review pass of a two-pass run; a tasks-only run carries neither. The legacy `build.review_patience` key is not parsed (the setting moved to `build.review_executor.patience`). + ## Examples ```bash -goga build docs/plans/json-export.md -goga build docs/plans/json-export.md --dry-run -goga build docs/plans/json-export.md --skip-manifest-check -goga build docs/plans/json-export.md -e ANTHROPIC_API_KEY=sk-xxx +goga build .goga/history//json-export/plan.md +goga build .goga/history//json-export/plan.md --dry-run +goga build .goga/history//json-export/plan.md --skip-manifest-check +goga build .goga/history//json-export/plan.md -e ANTHROPIC_API_KEY=sk-xxx # Force-refresh the image before launch (build when dockerfile is declared, else pull) -goga build docs/plans/json-export.md --update +goga build .goga/history//json-export/plan.md --update # Route container traffic through a corporate proxy and add a local host entry -goga build docs/plans/json-export.md --proxy http://corp:3128 --add-host foo.local:127.0.0.1 +goga build .goga/history//json-export/plan.md --proxy http://corp:3128 --add-host foo.local:127.0.0.1 # Wipe persistent ralph-loop state before launch (start fresh) -goga build docs/plans/json-export.md --clean +goga build .goga/history//json-export/plan.md --clean # Without --clean, ralph-loop state persists across runs of the same project+branch -goga build docs/plans/json-export.md -goga build docs/plans/json-export.md # second run reuses .ralphex/ from the first +goga build .goga/history//json-export/plan.md +goga build .goga/history//json-export/plan.md # second run reuses .ralphex/ from the first ``` ## Exit codes @@ -97,13 +100,13 @@ goga build docs/plans/json-export.md # second run reuses .ralphex/ from the fir | Code | Meaning | |---|---| | `0` | Build completed successfully | -| `1` | Build failed (Docker not found, config error, `build` section missing, top-level `image` unset, uncommitted `CODEMANIFEST` files, invalid review configuration, two-pass review combined with worktree, a missing `ralphex` binary or a rejected ralphex launch, a fatal `docker build` under `--update`, a ralphex error, or a refused pre-launch version check — a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version; see [`goga build`](../cli/build.md)) | +| `1` | Build failed (Docker not found, config error, `build` section missing, top-level `image` unset, a missing `build.task_executor.agent`, uncommitted `CODEMANIFEST` files, invalid review configuration, two-pass review combined with worktree, a missing `ralphex` binary or a rejected ralphex launch, a fatal `docker build` under `--update`, a ralphex error, or a refused pre-launch version check — a host–image (major, minor) mismatch, an image that cannot answer the version probe, or an undeterminable host version; see [`goga build`](../features/build/cli.md)) | ## What happens next -- The completed plan now lives in `docs/plans/completed/` — nothing further reads it, but it stays for reference. +- The completed plan now lives in `.goga/history///completed/` — nothing further reads it, but it stays for reference. - Test the produced implementation manually. - If bugs or defects are found — fix them with [`change`](change.md). - Once the implementation is stable — run [`accept`](accept.md) for final sign-off. -See the full CLI reference: [`goga build`](../cli/build.md). +See the full CLI reference: [`goga build`](../features/build/cli.md). diff --git a/docs/workflow/define.md b/docs/workflow/define.md index 81631b03..4c1f17bc 100644 --- a/docs/workflow/define.md +++ b/docs/workflow/define.md @@ -12,7 +12,7 @@ Examples use the slash-command form `/goga:`, which works in agents tha ## Output artifact -`docs/defines/.md` — a PRD with sections for the problem, users, goals, user experience, requirements, constraints, scope, and success criteria. If a PRD with the same topic already exists, the collision is handled explicitly rather than silently overwriting unrelated work. +`.goga/history///prd.md` — a PRD with sections for the problem, users, goals, user experience, requirements, constraints, scope, and success criteria. If a PRD with the same topic already exists, the collision is handled explicitly rather than silently overwriting unrelated work. ## Pipeline @@ -42,7 +42,7 @@ Each stage declares what it consumes and produces: ## Conflicts -Any stage may report a conflict with an earlier decision. Conflicts are resolved before the pipeline continues: a resolver reconsiders the decisions across the entire context, and if an earlier decision changes, the earliest affected stage is re-run from that point. A PRD is never generated while unresolved conflicts remain. +Any stage may report a conflict with an earlier decision. Conflicts are resolved before the pipeline continues: a resolver reconsiders the decisions across the entire context, and if an earlier decision changes, the earliest affected stage is re-run from that point. A PRD is never generated while unresolved conflicts remain. A subskill that fails to produce its declared output is never fabricated or silently skipped — it is retried only when safe, otherwise the workflow stops and reports the failed stage. ## When to use diff --git a/docs/workflow/design.md b/docs/workflow/design.md index 3c10bd42..3f105e9b 100644 --- a/docs/workflow/design.md +++ b/docs/workflow/design.md @@ -5,19 +5,22 @@ Produce a detailed design document based on CODEMANIFEST changes introduced by ` ## Synopsis ```text -/goga:design +/goga:design ``` +The topic follows the current git branch; the argument is an optional free-form description. + Examples use the slash-command form `/goga:`, which works in agents that consume the goga command bundle (`claude`, `opencode`, `qwen`). Codex and cursor do not register commands — invoke the skill directly: `goga-design` (Codex: `$goga-design`). See [Workflow](index.md). ## Output artifact -`docs/design/.md` — a design document with these sections: +`.goga/history///design.md` — a design document with these sections: - Contract changes (added/removed/modified entities) - Applied CODEMANIFEST fixes - Entity interactions and data flows (diagrams) - Code Stack Trace (verified logical chains per entry point) +- Algorithm design (per entity: responsibility, algorithm, errors, edge cases) - Cross-cutting concerns (error handling, validation, logging, caching, concurrency) - Usages analysis (per practice: what/where/why/how) - `.usages/` updates (per cell) @@ -72,23 +75,14 @@ The document does **not** contain implementation code and does **not** produce a | Step | Action | |---|---| | 1. Write from template | Use the design-doc template. | -| 2. Save | Path: `docs/design/.md`. Create directory if missing; overwrite if exists. | - -## Resolving the function name - -If `` is omitted: - -1. Scan `docs/design/`. -2. **Single file** — use automatically. -3. **Multiple files** — ask the user. -4. **Empty or missing** — halt and ask the user to run `design` first. +| 2. Save | Path: `.goga/history///design.md`. Create directory if missing; overwrite if exists. | ## Inputs and outputs | | | |---|---| | **Input** | Modified CODEMANIFEST files (from `apply`) | -| **Output** | `docs/design/.md` | +| **Output** | `.goga/history///design.md` | ## What happens next diff --git a/docs/workflow/discover.md b/docs/workflow/discover.md index 3026690a..8eeced9a 100644 --- a/docs/workflow/discover.md +++ b/docs/workflow/discover.md @@ -12,7 +12,7 @@ Examples use the slash-command form `/goga:`, which works in agents tha ## Output artifact -`docs/proposals/.md` — a short ADR (slug name, lowercase kebab-case; the directory is created lazily if needed). An ADR is 1–3 sentences: the context, the decision, and why. Optional sections (`Status`, `Considered Options`, `Consequences`) are included only when they add genuine value. +`.goga/history///adr.md` — a short ADR (the topic comes from the current git branch; the directory is created lazily if needed). An ADR is 1–3 sentences: the context, the decision, and why. Optional sections (`Status`, `Considered Options`, `Consequences`) are included only when they add genuine value. ## Algorithm @@ -49,6 +49,8 @@ The context only grows — entries are appended, never overwritten. After each r The ADR's "why" only makes sense if the vocabulary it uses is sharp — this is why term discipline runs throughout the interview, not as a final pass. +Questions drifting into contracts — signatures, cell boundaries, wiring — are not answered: they are recorded in the ADR as unresolved and the interview moves on. + ### Completion The interview is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. The ADR is written only after the user confirms shared understanding. @@ -68,7 +70,7 @@ If a decision is easy to reverse, skip it. If it is not surprising, nobody will | | | |---|---| | **Input** | A decision worth recording, in natural language | -| **Output** | `docs/proposals/.md` — a short ADR | +| **Output** | `.goga/history///adr.md` — a short ADR | ## What happens next diff --git a/docs/workflow/index.md b/docs/workflow/index.md index b41671ad..73f7510c 100644 --- a/docs/workflow/index.md +++ b/docs/workflow/index.md @@ -9,17 +9,17 @@ Goga organizes feature development as two global workrounds — **refinement** a | **Refinement** | Settle the *what and why*: turn a product idea into a verified engineering task | `define` → `discover` → `propose` → `review(task)` | | **Development** | Build the *how*: from a verified task to accepted implementation | `brainstorm` → `apply` → `design` → `plan` → `build` → `change` → `accept` | -The refinement workround ends with a task review: once the task in `docs/tasks/` is verified, the product side is settled and development can start. The development workround picks up the verified task and takes it all the way to an acceptance report. +The refinement workround ends with a task review: once the task in `.goga/history///task.md` is verified, the product side is settled and development can start. The development workround picks up the verified task and takes it all the way to an acceptance report. -> Command examples in this section use the slash-command form `/goga:`. This form works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](../cli/connect.md)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-` (Codex uses the `$` prefix — for example, `$goga-propose`). +> Command examples in this section use the slash-command form `/goga:`. This form works in agents that consume the goga command bundle — currently `claude`, `opencode`, and `qwen` (see [`goga connect`](../features/connect/cli.md)). Codex and cursor do not register commands; in those agents invoke the skill directly: `goga-` (Codex uses the `$` prefix — for example, `$goga-propose`). ### Refinement Refinement turns a raw idea into a task definition that is worth engineering. Three entry depths exist, depending on how much elaboration the idea needs: -- **[`define`](define.md)** — the product is not yet understood: the problem, users, goals, and success criteria are extracted into a PRD (`docs/defines/.md`). The longest refinement path opens here. -- **[`discover`](discover.md)** — the product is clear, but hard-to-reverse technical decisions are not: `discover` interviews them out and records short ADRs (`docs/proposals/.md`). -- **[`propose`](propose.md)** — everything above is already settled: the request is formulated directly as a structured task (`docs/tasks/.md`). +- **[`define`](define.md)** — the product is not yet understood: the problem, users, goals, and success criteria are extracted into a PRD (`.goga/history///prd.md`). The longest refinement path opens here. +- **[`discover`](discover.md)** — the product is clear, but hard-to-reverse technical decisions are not: `discover` interviews them out and records short ADRs (`.goga/history///adr.md`). +- **[`propose`](propose.md)** — everything above is already settled: the request is formulated directly as a structured task (`.goga/history///task.md`). ``` define → discover → propose → review(task) @@ -90,18 +90,20 @@ define → discover → propose → review(task) | Command | Workround | Input artifact | Output artifact | |---|---|---|---| -| [`define`](define.md) | Refinement | Product idea | `docs/defines/.md` (PRD) | -| [`discover`](discover.md) | Refinement | A decision worth recording | `docs/proposals/.md` (short ADR) | -| [`propose`](propose.md) | Refinement | User request text | `docs/tasks/.md` | -| [`review`](review.md) | both | Any artifact in `docs/` | Review report | -| [`brainstorm`](brainstorm.md) | Development | `docs/tasks/.md` | `docs/arch/.md` | -| [`apply`](apply.md) | Development | `docs/arch/.md` | Cell file structure (CODEMANIFEST, `.usages/`) | -| [`design`](design.md) | Development | Modified CODEMANIFEST | `docs/design/.md` | -| [`plan`](plan.md) | Development | `docs/design/.md` | `docs/plans/.md` | -| [`build`](build.md) | Development | `docs/plans/.md` | Implemented code (via a ralph-loop); the plan moves to `docs/plans/completed/` on success | +| [`define`](define.md) | Refinement | Product idea | `.goga/history///prd.md` (PRD) | +| [`discover`](discover.md) | Refinement | A decision worth recording | `.goga/history///adr.md` (short ADR) | +| [`propose`](propose.md) | Refinement | User request text | `.goga/history///task.md` | +| [`review`](review.md) | both | Any reviewable artifact in `.goga/history/` — task, arch, design, plan — (or a cell) | Review report | +| [`brainstorm`](brainstorm.md) | Development | `.goga/history///task.md` (or a raw description) | `.goga/history///arch.md` | +| [`apply`](apply.md) | Development | `.goga/history///arch.md` | Cell file structure (CODEMANIFEST, `.usages/`) | +| [`design`](design.md) | Development | Modified CODEMANIFEST | `.goga/history///design.md` | +| [`plan`](plan.md) | Development | `.goga/history///design.md` | `.goga/history///plan.md` | +| [`build`](build.md) | Development | `.goga/history///plan.md` | Implemented code (via a ralph-loop); the plan moves to `.goga/history///completed/` on success | | [`change`](change.md) | Development | Change description | Modified code + reconciled contracts and usages | | [`accept`](accept.md) | Development | Completed implementation | Final acceptance report | +Workflow artifacts live at `.goga/history///.md` (`` ∈ `todo | prd | adr | task | arch | design | plan`): `` is the current year as `YYYY`, and `` is a lowercase kebab-case slug — non-ASCII dropped, anything outside `[a-z0-9]` as `-`, repeat hyphens collapsed, edges trimmed (branch `release/1.3.0` → `release-1-3-0`). The topic directory is created lazily by the stage that first writes into it, and the whole `.goga/history/` tree is meant to stay out of git — add it to your `.gitignore`. `goga pipeline -t ` switches onto the branch hosting an existing topic before a run, or creates both a fresh branch and its fresh topic when nothing hosts it; `goga topics create ` prepares both directly. + ## Next steps - Product side not settled — open [`define`](define.md). diff --git a/docs/workflow/plan.md b/docs/workflow/plan.md index 611b4334..4a2ac4ff 100644 --- a/docs/workflow/plan.md +++ b/docs/workflow/plan.md @@ -5,14 +5,16 @@ Compile a design document into a ralph-loop-compatible execution plan. The plan ## Synopsis ```text -/goga:plan +/goga:plan ``` +The topic follows the current git branch; the argument is an optional free-form description. + Examples use the slash-command form `/goga:`, which works in agents that consume the goga command bundle (`claude`, `opencode`, `qwen`). Codex and cursor do not register commands — invoke the skill directly: `goga-plan` (Codex: `$goga-plan`). See [Workflow](index.md). ## Output artifact -`docs/plans/.md` — an execution plan with this structure: +`.goga/history///plan.md` — an execution plan with this structure: - `### Task N: ` headings define individual tasks - `- [ ]` checkboxes mark incomplete items @@ -32,15 +34,15 @@ Only ONE task is executed per ralph-loop iteration. | 3 | Load language implementation rules via `goga-lang-disp`. | | 4 | Load the plan template from `output-template.md`. | | 5 | Load project conventions from `conventions.md`. | -| 6 | Read the design document from `docs/design/<function-name>.md`. Halt if missing. | +| 6 | Read the design document from `.goga/history/<year>/<topic>/design.md`. Halt if missing. | ### Phase 2. Compile design document into plan | Step | Action | |---|---| -| 1. Extract data from design | Contract changes → task scope; applied fixes → context; entity interactions → diagrams; code stack traces → verified chains; algorithm design → checkboxes; cross-cutting concerns → distributed across tasks; usages analysis → task context; `.usages/` updates → tasks; test stack traces → test instructions. Transfer traces and diagrams **verbatim**, do not summarize. | +| 1. Extract data from design | Contract changes → task scope; applied fixes → context; entity interactions → diagrams; code stack traces → verified chains; algorithm design → checkboxes; cross-cutting concerns → distributed across tasks; usages analysis → task context; `.usages/` updates → tasks; test stack traces → test instructions; additional instructions → task context. Transfer traces and diagrams **verbatim**, do not summarize. | | 2. Compile into ralph-loop tasks | For each entity: create tasks following DSL compilation rules, cell boundaries, ralphex plan-format requirements, task ordering, TDD workflow, task formation rules, templates, and project conventions. | -| 3. Save the plan | Write to `docs/plans/<function-name>.md`. Create directory if missing. | +| 3. Save the plan | Write to `.goga/history/<year>/<topic>/plan.md`. Create directory if missing. | ### Phase 3. Plan verification @@ -115,21 +117,16 @@ After completion: `→ REVIEW → APPROVAL → NEXT_TASK`. **Prohibited:** creating new cells, defining new cell-level interfaces outside the current one, replacing contract entities with internal-only abstractions, ignoring `location`, modifying `CODEMANIFEST` files (read-only). -## Resolving the function name - -If `<function-name>` is omitted: +## Resolving the design document -1. Scan `docs/design/`. -2. **Single file** — use automatically. -3. **Multiple files** — ask the user. -4. **Empty or missing** — halt and ask the user to run `design` first. +The design document is read from the path printed by `goga history path -f design.md` (the topic of the current git branch). If the file does not exist — halt and ask the user to run `design` first. ## Inputs and outputs | | | |---|---| -| **Input** | `docs/design/<function-name>.md` | -| **Output** | `docs/plans/<function-name>.md` | +| **Input** | `.goga/history/<year>/<topic>/design.md` | +| **Output** | `.goga/history/<year>/<topic>/plan.md` | ## What happens next diff --git a/docs/workflow/propose.md b/docs/workflow/propose.md index b66e783f..48f1964b 100644 --- a/docs/workflow/propose.md +++ b/docs/workflow/propose.md @@ -12,7 +12,7 @@ Examples use the slash-command form `/goga:<command>`, which works in agents tha ## Output artifact -`docs/tasks/<topic>.md` — a structured task with the following sections: +`.goga/history/<year>/<topic>/task.md` — a structured task with the following sections: - Current State - Description @@ -70,7 +70,7 @@ Assess scale (number of entities, subsystems, interaction complexity). Present a ### Phase 7. Task persistence -Write the task to `docs/tasks/<topic>.md` using the task template. Present a summary: task name, stack, dependency count, scope, risks. +Write the task to `.goga/history/<year>/<topic>/task.md` using the task template. Present a summary: task name, stack, dependency count, scope, risks. ## Dialogue rules @@ -83,7 +83,7 @@ Write the task to `docs/tasks/<topic>.md` using the task template. Present a sum | | | |---|---| | **Input** | Free-form description in natural language | -| **Output** | `docs/tasks/<topic>.md` | +| **Output** | `.goga/history/<year>/<topic>/task.md` | ## What happens next diff --git a/docs/workflow/review.md b/docs/workflow/review.md index c8993ec8..f7c101de 100644 --- a/docs/workflow/review.md +++ b/docs/workflow/review.md @@ -13,13 +13,18 @@ Examples use the slash-command form `/goga:<command>`, which works in agents tha ## Dispatch logic -| Argument shape | Type | Specialized skill | +When the argument is a path under `.goga/history/`, it must match `.goga/history/<year>/<topic>/<kind>.md` (a 4-digit `<year>`); the review type comes from the `<kind>` filename: + +| Artifact kind | Type | Specialized skill | |---|---|---| -| Path under `docs/tasks/` | `task` | `goga-review-task` | -| Path under `docs/arch/` | `architecture` | `goga-review-arch` | -| Path under `docs/design/` | `design` | `goga-review-design` | -| Path under `docs/plans/` | `plan` | `goga-review-plan` | -| Cell directory or other | `cell` | `goga-review-cell` | +| `prd.md` | `prd` | *review not supported* (no dedicated skill yet) | +| `adr.md` | `adr` | *review not supported* (no dedicated skill yet) | +| `task.md` | `task` | `goga-review-task` | +| `arch.md` | `architecture` | `goga-review-arch` | +| `design.md` | `design` | `goga-review-design` | +| `plan.md` | `plan` | `goga-review-plan` | + +Any other filename, a path outside `.goga/history/`, or a cell directory → **cell** (`goga-review-cell`). For `prd` and `adr` the dispatcher stops with "review not supported" — no dedicated review skill exists for those kinds yet. If the argument is empty, the dispatcher asks which type to review via `AskUserQuestion`. @@ -43,7 +48,7 @@ The task review `review(task)` is the checkpoint that closes refinement: it veri ## Algorithm — Task review (`goga-review-task`) -Validates `docs/tasks/<topic>.md` for completeness, correctness, and consistency. +Validates `.goga/history/<year>/<topic>/task.md` for completeness, correctness, and consistency. | Phase | Action | |---|---| @@ -61,7 +66,7 @@ Validates `docs/tasks/<topic>.md` for completeness, correctness, and consistency ## Algorithm — Architecture review (`goga-review-arch`) -Validates `docs/arch/<topic>.md` for semantic correctness across the whole plan — model cohesion, cell boundaries, requirement sufficiency. +Validates `.goga/history/<year>/<topic>/arch.md` for semantic correctness across the whole plan — model cohesion, cell boundaries, requirement sufficiency. | Phase | Action | |---|---| @@ -79,7 +84,7 @@ Validates `docs/arch/<topic>.md` for semantic correctness across the whole plan ## Algorithm — Design review (`goga-review-design`) -Verifies `docs/design/<feature-name>.md` for logical correctness by tracing the full code stack for each entry point and test scenario. +Verifies `.goga/history/<year>/<topic>/design.md` for logical correctness by tracing the full code stack for each entry point and test scenario. | Phase | Action | |---|---| @@ -92,7 +97,7 @@ Verifies `docs/design/<feature-name>.md` for logical correctness by tracing the ## Algorithm — Plan review (`goga-review-plan`) -Verifies `docs/plans/<feature-name>.md` for completeness and correctness before passing to the ralph-loop. +Verifies `.goga/history/<year>/<topic>/plan.md` for completeness and correctness before passing to the ralph-loop. | Phase | Action | |---|---| @@ -117,8 +122,8 @@ Reviews a cell across three dimensions and proposes remediation per dimension. |---|---| | 1. Load context | Load `goga-lang-disp`, `goga-cell`, `goga-cookbook`; read CODEMANIFEST and source files; enumerate cell files; read `.usages/*.md`; verify facade. | | 2. Run tools | `goga lint` (fix syntax before analysis), `goga schema` (for context). | -| 3. Analysis 1 — Code vs Requirements | Compare signatures, methods, properties, location validity, behavioral conformance, import utilization. Proposed action: `goga-design` (`/goga:design`) in **brainstorm** mode. | -| 4. Analysis 2 — Requirements vs Code/Usages | Find undocumented entities, inaccurate descriptions, poor annotation authoring (purpose statement, parameter descriptions, `Algorithm:`/`Requirements:` sections). Proposed action: edit CODEMANIFEST, then `goga-design` (`/goga:design`) in **changes** mode. | +| 3. Analysis 1 — Code vs Requirements | Compare signatures, methods, properties, location validity, behavioral conformance, import utilization. Proposed action: run `goga-design` (`/goga:design`). | +| 4. Analysis 2 — Requirements vs Code/Usages | Find undocumented entities, inaccurate descriptions, poor annotation authoring (purpose statement, parameter descriptions, `Algorithm:`/`Requirements:` sections). Proposed action: edit CODEMANIFEST, then run `goga-design` (`/goga:design`). | | 5. Analysis 3 — Usages | Verify existence, annotation references, adequacy, categorization. Proposed action: create or update `.usages/*.md` directly. | | 6. Execute approved actions | For each approved action, execute the proposed remediation; run `goga lint` after all actions and fix any errors. | diff --git a/goga/CODEMANIFEST b/goga/CODEMANIFEST index a8baae1f..4d3b8dd3 100644 --- a/goga/CODEMANIFEST +++ b/goga/CODEMANIFEST @@ -13,6 +13,9 @@ Imports: - upgrade - install - uninstall + - history + - hooks + - topics Usages: - cli-commands From: goga/commands @@ -80,6 +83,9 @@ app(): - `upgrade` - `install` - `uninstall` + - `history` + - `hooks` + - `topics` --- diff --git a/goga/assets/pipelines/bugfix.yml b/goga/assets/pipelines/bugfix.yml index c96aee7c..89780613 100644 --- a/goga/assets/pipelines/bugfix.yml +++ b/goga/assets/pipelines/bugfix.yml @@ -6,6 +6,8 @@ description: "Bug fixing process" title: "Root-cause analysis and resolution" communication: true prompt: | + Use the TODO file at the path printed by `goga history path -f todo.md`, if it exists. + Cell convention: - When changing CODEMANIFEST and usage files, you **MUST** ensure the current state, **NOT** the changelogs. - Keep algorithms, requirements, constraints and usages succinct and to the point. Use clear bullet points and focus strictly on the required outcomes. diff --git a/goga/assets/pipelines/development.yml b/goga/assets/pipelines/development.yml index 6d06a059..b0df691f 100644 --- a/goga/assets/pipelines/development.yml +++ b/goga/assets/pipelines/development.yml @@ -6,9 +6,6 @@ description: "Development process" title: "Task-based architecture development" communication: true prompt: | - Use the task `docs/tasks/<git branch --show-current>.md`, if it exists - Save the architecture plan as `<git branch --show-current>.md` - **CODEMANIFEST files** must be described at a functional and business-logic level, remaining strictly implementation-agnostic: - Focus on defining "what" the system should achieve (expected behavior, business rules, inputs, and outputs) rather than "how" to code it. - Avoid embedding code snippets, database queries, or specific technology details in the requirement descriptions to leave room for optimal engineering design. @@ -33,48 +30,35 @@ description: "Development process" - name: architecture-review title: "Review of the created architectural plan" communication: true - prompt: | - Review the architecture plan `<git branch --show-current>.md` skills: - goga-review-arch - name: apply-architecture title: "Apply the created architectural plan" - prompt: | - Apply the architecture plan `<git branch --show-current>.md` skills: - goga-apply - name: code-design title: "Designing architecture into code" communication: true - prompt: | - Save the design document as `<git branch --show-current>.md` skills: - goga-design - name: design-review title: "Review of the created design plan" communication: true - prompt: | - Review the design document `<git branch --show-current>.md` skills: - goga-review-design - name: coding-plan title: "Create the coding plan" communication: true - prompt: | - Use the design document `<git branch --show-current>.md` - Save the plan as `<git branch --show-current>.md` skills: - goga-plan - name: plan-review title: "Review of the created coding plan" communication: true - prompt: | - Review the plan `<git branch --show-current>.md` skills: - goga-review-plan @@ -84,14 +68,11 @@ description: "Development process" prompt: | Commit all added and modified files. - Constraints: - - Except changes in `docs/<defines|proposals|tasks|arch|design|plans>`. - - name: accept-result title: "Contracts & coverage audit" communication: true trigger: manual prompt: | - Commit changes after fixes + Commit changes after fixes. skills: - goga-accept diff --git a/goga/assets/pipelines/patch.yml b/goga/assets/pipelines/patch.yml index 934717e0..8ae5e188 100644 --- a/goga/assets/pipelines/patch.yml +++ b/goga/assets/pipelines/patch.yml @@ -6,6 +6,8 @@ description: "Refactoring or miniman changes process" title: "Formalize the task, make the plan and implement it" communication: true prompt: | + Use the TODO file at the path printed by `goga history path -f todo.md`, if it exists. + Cell convention: - When changing CODEMANIFEST and usage files, you **MUST** ensure the current state, **NOT** the changelogs. - Keep algorithms, requirements, constraints and usages succinct and to the point. Use clear bullet points and focus strictly on the required outcomes. diff --git a/goga/assets/pipelines/refinement.yml b/goga/assets/pipelines/refinement.yml index 905105d7..7b9af202 100644 --- a/goga/assets/pipelines/refinement.yml +++ b/goga/assets/pipelines/refinement.yml @@ -6,7 +6,9 @@ description: "Task refinement process" title: "Product definition & create PRD" communication: true prompt: | - Save the PRD file as `docs/defines/<git branch --show-current>.md` + Use the TODO file at the path printed by `goga history path -f todo.md`, if it exists. + + If the TODO file does not exist — ask user about task. Constrains: - Don't research a project until you receive the task @@ -17,10 +19,13 @@ description: "Task refinement process" title: "Technical discovery & create ADR" communication: true prompt: | - Save the ADR file as `docs/proposals/<git branch --show-current>.md` + Use the PRD file at the path printed by `goga history path -f prd.md`, if it exists. + + If the PRD file does not exist try TODO file at the path printed by `goga history path -f todo.md`. + If the PRD and TODO files do not exist — ask user about task. - Use `docs/defines/<git branch --show-current>.md` as the PRD file, if it exists. - If PRD file does not exist — ask user about task. + Constrains: + - Don't research a project until you receive the task Communication: - You **MUST** follow the `File-Based Dialog Protocol` for every round of questions. @@ -31,18 +36,16 @@ description: "Task refinement process" title: "Task decomposition & create Task(s)" communication: true prompt: | - Save the task file as `docs/tasks/<git branch --show-current>.md` - - Use `docs/proposals/<git branch --show-current>.md` as the task file, if it exists. - If propose does not exist — try `docs/defines/<git branch --show-current>.md` as the PRD file. - If PRD file does not exists — ask user about task. + Use the ADR at the path printed by `goga history path -f adr.md` as the input for task formulation, if it exists. + + If the ADR does not exist — try the PRD file at the path printed by `goga history path -f prd.md`. + If the ADR and PRD files do not exist try TODO file at the path printed by `goga history path -f todo.md`. + If the PRD, ADR and TODO files do not exists — ask user about task. skills: - goga-propose - name: task-review title: "Review of the created task" communication: true - prompt: | - Review the task `docs/tasks/<git branch --show-current>.md` skills: - goga-review-task diff --git a/goga/assets/skills/goga-apply/SKILL.md b/goga/assets/skills/goga-apply/SKILL.md index 39aee88f..5010d162 100644 --- a/goga/assets/skills/goga-apply/SKILL.md +++ b/goga/assets/skills/goga-apply/SKILL.md @@ -2,7 +2,7 @@ name: goga-apply description: Materialize an architectural plan into the cells file structure --- -You are an architectural plan materialization engineer. You transform plans from `docs/arch/<topic>.md` into a cells file structure (CODEMANIFEST, `.usages/`). +You are an architectural plan materialization engineer. You transform plans from the path printed by `goga history path -f arch.md` into a cells file structure (CODEMANIFEST, `.usages/`). ## Dispatch @@ -12,18 +12,6 @@ The command invokes the skill: Arguments: $ARGUMENTS -Retain the original arguments for the duration of the session. - -### Resolving the architecture file - -Resolve `<topic>`: - -1. **Arguments supplied** — use the arguments as `<topic>`. -2. **No arguments** — scan the `docs/arch/` directory: - - **Directory missing or empty** — halt and report the error. - - **Single file** — use its filename (without extension) as `<topic>`. - - **Multiple files** — present the list via AskUserQuestion and prompt for selection. - ## Pre-flight check: goga availability Before proceeding, verify tool availability: @@ -34,12 +22,19 @@ goga --help If the command is unavailable — halt and notify the user. +### Resolving the architecture file + +Check if the path printed by `goga history path -f arch.md` exists: + +- **Does not exist** — stop and ask the user to run `/goga:brainstorm` first. +- **Exists** — proceed to Materialization. + --- ## Materialization -Use the **Skill tool** to invoke `goga-cells-by-brainstorm` with `<topic>` as the argument. +Use the **Skill tool** to invoke `goga-cells-by-brainstorm` with the printed path as the argument. -The skill reads the plan from `docs/arch/<topic>.md` and materializes it into a cells file structure (CODEMANIFEST, `.usages/`). +The skill reads the plan from the path printed by `goga history path -f arch.md` and materializes it into a cells file structure (CODEMANIFEST, `.usages/`). --- diff --git a/goga/assets/skills/goga-brainstorm-intake/SKILL.md b/goga/assets/skills/goga-brainstorm-intake/SKILL.md index 87d29401..2f215d95 100644 --- a/goga/assets/skills/goga-brainstorm-intake/SKILL.md +++ b/goga/assets/skills/goga-brainstorm-intake/SKILL.md @@ -21,7 +21,7 @@ Classify the description into one of: - **Brief** — one sentence or a feature name - **Detailed** — a complete specification with requirements, constraints, examples -- **Task file** — path to `docs/tasks/<topic>.md` +- **Task file** — a path to a task file (e.g., the path printed by `goga history path -f task.md`) ### Phase 3. Read the task file if provided diff --git a/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md b/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md index cfcf3d5b..1883500b 100644 --- a/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md +++ b/goga/assets/skills/goga-brainstorm-plan-assembly/SKILL.md @@ -14,9 +14,9 @@ files for each cell — and writing it to disk. Use these reports for its specific purpose: -- **`[CELL_ASSEMBLY_REPORT]`** — use it for the assembled per-cell **CODEMANIFESTs and `.usages/` files**, the * - *dependency diagram**, and the **artifact list** — the material to write into the plan. -- **`[PRIMARY_ANALYSIS_REPORT]`** — use its **Topic** for the plan file name, its **Existing Cells & Schema** for the +- **`[CELL_ASSEMBLY_REPORT]`** — use it for the assembled per-cell **CODEMANIFESTs and `.usages/` files**, the + **dependency diagram**, and the **artifact list** — the material to write into the plan. +- **`[PRIMARY_ANALYSIS_REPORT]`** — use its **Topic** as the plan's short name, its **Existing Cells & Schema** for the project structure (file names and paths), and its **Artifact Resolution** to mark each cell as modified vs created anew. @@ -24,7 +24,8 @@ Use these reports for its specific purpose: ### Phase 1. Determine the topic -Determine `<topic>` — a short name from the **Topic** section of the `[PRIMARY_ANALYSIS_REPORT]`. +Resolve the topic directory — the path printed by `goga history path` (the current topic in the history tree). +Keep the **Topic** section of the `[PRIMARY_ANALYSIS_REPORT]` as the plan's short name. ### Phase 2. Assemble the plan structure @@ -62,7 +63,8 @@ What to check after implementing each artifact. ### Phase 4. Save the plan -Save the plan to `docs/arch/<topic>.md`. +Save the plan to the path printed by `goga history path -f arch.md` +(run `goga history ensure` first if the topic directory does not exist). ## WAIT @@ -76,7 +78,7 @@ Fill every section. No empty sections. # [ARCHITECTURE_PLAN] ## Topic -[Short name and the docs/arch/<topic>.md path] +[Short name and the path printed by `goga history path -f arch.md`] ## Implementation Order [Ordered list of cells, leaves to root, with rationale per cell] diff --git a/goga/assets/skills/goga-brainstorm-plan-verification/SKILL.md b/goga/assets/skills/goga-brainstorm-plan-verification/SKILL.md index 503f976f..70325300 100644 --- a/goga/assets/skills/goga-brainstorm-plan-verification/SKILL.md +++ b/goga/assets/skills/goga-brainstorm-plan-verification/SKILL.md @@ -19,7 +19,7 @@ Use these skills for verification: Use this artifact for its specific purpose: -- **`[ARCHITECTURE_PLAN]`** (at `docs/arch/<topic>.md`) — use it as the **object of verification**: its implementation +- **`[ARCHITECTURE_PLAN]`** (at the path printed by `goga history path -f arch.md`) — use it as the **object of verification**: its implementation order, per-cell CODEMANIFESTs and `.usages/` files, dependency map, and verification checklist, against which the DSL checks are run, failures are fixed in place, and the report is produced. diff --git a/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md b/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md index 18454112..4633a31a 100644 --- a/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md +++ b/goga/assets/skills/goga-brainstorm-primary-analysis/SKILL.md @@ -33,8 +33,8 @@ From the description and gathered facts, determine. The Description Type from th Task file) sets the expected depth — Brief input yields more dark zones; Detailed/Task-file input yields more constraints and acceptance criteria. -- **Topic** — a short topic name derived from the `[INTAKE_REPORT]` task summary (used by plan-assembly for - `docs/arch/<topic>.md`) +- **Topic** — a short topic name derived from the `[INTAKE_REPORT]` task summary (used by plan-assembly as the plan's + short name) - **Acceptance criteria** — if task-file input, folded verbatim/condensed from the `[INTAKE_REPORT]` "Acceptance Criteria" section; otherwise N/A - **Stack & external dependencies** — if task-file input, folded from the `[INTAKE_REPORT]` "Stack and Dependencies" @@ -75,7 +75,7 @@ Fill every section. No empty sections. # [PRIMARY_ANALYSIS_REPORT] ## Topic -[Short topic name derived from the [INTAKE_REPORT] task summary. +[Short topic name derived from the [INTAKE_REPORT] task summary.] ## Acceptance Criteria [If task-file input: verbatim/condensed list from the [INTAKE_REPORT] "Acceptance Criteria" section. Otherwise: N/A. diff --git a/goga/assets/skills/goga-brainstorm/SKILL.md b/goga/assets/skills/goga-brainstorm/SKILL.md index 83cd06bc..ce241bb3 100644 --- a/goga/assets/skills/goga-brainstorm/SKILL.md +++ b/goga/assets/skills/goga-brainstorm/SKILL.md @@ -11,7 +11,7 @@ strict order, never performing design work yourself — each stage is delegated ## Mission -Produce an architecture plan (`docs/arch/<topic>.md`) describing which cells, CODEMANIFEST files, and `.usages/` files +Produce an architecture plan (at the path printed by `goga history path -f arch.md`) describing which cells, CODEMANIFEST files, and `.usages/` files need to be created and in what order — designed collaboratively with the user through exploration, discussion, and refinement. @@ -21,7 +21,7 @@ Arguments: $ARGUMENTS Retain the original arguments for the duration of the session. -## Pre-check: goga Availability +## Pre-check: goga availability Before starting work, execute: @@ -42,7 +42,7 @@ Load these skills via the **Skill tool** before starting the pipeline. Actively use these skills during design and analysis. Proceed to Pipeline Phase 1. -## Requirements: +## Requirements - [DESIGN PRINCIPLE]: First design types and their interactions **without cell boundaries**, then group types into cells. @@ -74,7 +74,7 @@ Actively use these skills during design and analysis. Proceed to Pipeline Phase ## Dialogue Protocol -Applies to every interactive sub-skill (Phases 3-8). Enforce throughout: +Applies to every interactive sub-skill (Phases 3-10). Enforce throughout: 1. **Do not read implementation source code.** Design is conducted at the level of CODEMANIFEST, project schema, and practices. @@ -152,13 +152,13 @@ Execute each phase strictly sequentially — one phase at a time. After each pha ### Phase 9. Plan Assembly - Invoke: **goga-brainstorm-plan-assembly** - Reads: [CELL_ASSEMBLY_REPORT] + [PRIMARY_ANALYSIS_REPORT] -- Output: [ARCHITECTURE_PLAN] written to `docs/arch/<topic>.md` +- Output: [ARCHITECTURE_PLAN] written to the path printed by `goga history path -f arch.md` - WAIT: present plan to user, obtain confirmation - STOP if: plan incomplete ### Phase 10. Plan Verification - Invoke: **goga-brainstorm-plan-verification** -- Reads: [ARCHITECTURE_PLAN] (`docs/arch/<topic>.md`) +- Reads: [ARCHITECTURE_PLAN] (at the path printed by `goga history path -f arch.md`) - Output: [VERIFICATION_REPORT] - WAIT: present the final (fixed) plan and [VERIFICATION_REPORT] to the user, obtain final confirmation - STOP if: unresolved DSL errors; any verification gate failed diff --git a/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md b/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md index 47c061cb..92f4ab7b 100644 --- a/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md +++ b/goga/assets/skills/goga-cells-by-brainstorm/SKILL.md @@ -6,7 +6,7 @@ description: Creation and modification of cells by architecture plan ## Purpose -Creates new cells and modifies existing cells based on the architecture plan defined in `docs/arch/<topic>.md`. Materializes the plan into the cell file structure: +Creates new cells and modifies existing cells based on the architecture plan defined in the path printed by `goga history path -f arch.md`. Materializes the plan into the cell file structure: CODEMANIFEST, `.usages/`. --- @@ -51,8 +51,7 @@ Apply the loaded DSL specifications, DSL application principles, and language ru #### Step 1. Locate the plan file - If the argument contains a file path — use it directly -- If the argument contains only `<topic>` — search for the file `docs/arch/<topic>.md` -- If no argument is provided — discover all files in `docs/arch/` and present the list via **AskUserQuestion** +- If no argument is provided — use the path printed by `goga history path -f arch.md` - If the file is not found — halt and report the error to the user #### Step 2. Parse the plan structure diff --git a/goga/assets/skills/goga-define-prd/SKILL.md b/goga/assets/skills/goga-define-prd/SKILL.md index a72edbab..00b0b04f 100644 --- a/goga/assets/skills/goga-define-prd/SKILL.md +++ b/goga/assets/skills/goga-define-prd/SKILL.md @@ -1,6 +1,6 @@ --- name: goga-define-prd -description: +description: Generate the final PRD from the validated product definition --- # goga-define-prd @@ -170,12 +170,12 @@ Include, where relevant: - primary flow; - meaningful alternative flows; - important states; -- failure behaviour; +- failure behavior; - recovery; - user-visible feedback; - consequences of important actions. -The section should be detailed enough that engineering can understand the intended experience without inventing product behaviour. +The section should be detailed enough that engineering can understand the intended experience without inventing product behavior. --- @@ -185,12 +185,12 @@ Present the product requirements in a clear and structured form. Requirements should describe: -- required product behaviour; +- required product behavior; - conditions; - business rules; - permissions; - important states; -- failure behaviour; +- failure behavior; - required user-visible information. Do not add implementation details. @@ -290,7 +290,7 @@ The document should be precise enough to support engineering design while remain ## Technical Details -Technical details are allowed only when they are necessary to describe product behaviour. +Technical details are allowed only when they are necessary to describe product behavior. For example: @@ -319,7 +319,7 @@ Perform a final internal check before producing the document. Verify: - Every goal is supported by the user experience. -- Every important user experience behaviour is represented in requirements. +- Every important user experience behavior is represented in requirements. - Requirements respect constraints. - Scope contains everything necessary to solve the problem. - Success criteria demonstrate the intended outcome. @@ -341,11 +341,9 @@ The document must be self-contained and understandable without access to the int The PRD must contain only validated product decisions. -The final artifact will be saved by the orchestrator to: - -```text -docs/defines/<topic>.md -``` +The final artifact will be saved by the orchestrator at the path printed by +`goga history path -f prd.md` (the orchestrator runs `goga history ensure` +first if the topic directory does not exist). Do not create additional PRD files. diff --git a/goga/assets/skills/goga-define/SKILL.md b/goga/assets/skills/goga-define/SKILL.md index 32739ebb..765ac746 100644 --- a/goga/assets/skills/goga-define/SKILL.md +++ b/goga/assets/skills/goga-define/SKILL.md @@ -1,6 +1,6 @@ --- name: goga-define -description: +description: Orchestrate the product definition pipeline and generate the PRD --- # goga-define @@ -354,13 +354,8 @@ Once the product definition is validated: 1. invoke `goga-define-prd`; 2. provide the complete validated context; 3. receive the final Markdown document; -4. save it as: - -```text -docs/defines/<topic>.md -``` - -`<topic>` should be derived from the product change using a concise, filesystem-safe topic name. +4. save it at the path printed by `goga history path -f prd.md` + (run `goga history ensure` first if the topic directory does not exist). Do not overwrite an unrelated existing PRD. @@ -403,11 +398,8 @@ The orchestrator must not formulate or resolve the decision itself. ## Output -The primary output of `goga-define` is one Markdown file: - -```text -docs/defines/<topic>.md -``` +The primary output of `goga-define` is one Markdown file saved at the path +printed by `goga history path -f prd.md`. The orchestrator should provide a concise completion message containing: diff --git a/goga/assets/skills/goga-design-by-changes/SKILL.md b/goga/assets/skills/goga-design-by-changes/SKILL.md index 70db4c91..b739d1e5 100644 --- a/goga/assets/skills/goga-design-by-changes/SKILL.md +++ b/goga/assets/skills/goga-design-by-changes/SKILL.md @@ -326,10 +326,9 @@ Write results to a file using the template from `design-doc-template.md`. #### Step 2: Save -Path: `docs/design/<feature-name>.md`. +Path: the path printed by `goga history path -f design.md`. -- Prompt for the feature name if not obvious -- Create the `docs/design/` directory if it does not exist +- Run `goga history ensure` first if the topic directory does not exist - Overwrite if the file already exists --- diff --git a/goga/assets/skills/goga-design-by-changes/design-doc-template.md b/goga/assets/skills/goga-design-by-changes/design-doc-template.md index fedc79ce..f4b9f1f8 100644 --- a/goga/assets/skills/goga-design-by-changes/design-doc-template.md +++ b/goga/assets/skills/goga-design-by-changes/design-doc-template.md @@ -1,12 +1,14 @@ # Design Document Template -The agent persists this document at `docs/design/<feature-name>.md`. +The agent persists this document at the path printed by `goga history path -f design.md`. This is a **complete architectural specification** — every detail fully elaborated. --- -# Design Document: `<feature-name>` +# Design Document: `<topic>` + +<!-- `<topic>` — the topic name (the topic directory under `.goga/history/<year>/<topic>/`) --> ## Contract Changes diff --git a/goga/assets/skills/goga-discover/SKILL.md b/goga/assets/skills/goga-discover/SKILL.md index 3802c4b0..6e7890c2 100644 --- a/goga/assets/skills/goga-discover/SKILL.md +++ b/goga/assets/skills/goga-discover/SKILL.md @@ -23,7 +23,7 @@ Finding _facts_ is your job, never the user's. When a frontier question needs a The interview is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not write the ADR until the user confirms you have reached a shared understanding. -Once confirmed, write the ADR to `docs/proposals/<topic>.md` (slug name, lowercase kebab-case; create the directory lazily if needed), following `adr-template.md` from the current skill directory. +Once confirmed, write the ADR to the path printed by `goga history path -f adr.md` (run `goga history ensure` first if the topic directory does not exist), following `adr-template.md` from the current skill directory. ## Research @@ -43,7 +43,7 @@ To understand the architectural diagram of the project, use: goga schema ``` -To understand the json of diagram, use `goga schema --help`. +For the JSON structure of the diagram, see `goga schema --help`. ## Context structure diff --git a/goga/assets/skills/goga-plan-by-design/SKILL.md b/goga/assets/skills/goga-plan-by-design/SKILL.md index 2312fb1b..e98e2b8f 100644 --- a/goga/assets/skills/goga-plan-by-design/SKILL.md +++ b/goga/assets/skills/goga-plan-by-design/SKILL.md @@ -58,7 +58,7 @@ Use for: #### Step 6: Load Design Document -Read the file from `docs/design/<feature-name>.md`. `<feature-name>` is taken from skill arguments. +Read the file from the path printed by `goga history path -f design.md`. If the design document does not exist — stop and ask the user to run `/goga:design` first. --- @@ -101,11 +101,10 @@ Use the `goga-cell` skill for correct interpretation of DSL elements during comp #### Step 3: Save the Plan -Write the plan to `docs/plans/<feature-name>.md` using the template from `output-template.md`. +Write the plan to the path printed by `goga history path -f plan.md`, using the template from `output-template.md`. -`<feature-name>` — a short descriptive feature name (e.g., `http-client`, `auth-module`). -The name should reflect the plan's scope, not the Cell name. -Create the `docs/plans/` directory if it does not exist. +The topic is the current one in the history tree; name it to reflect the plan's scope, not the Cell name. +Run `goga history ensure` first if the topic directory does not exist. --- diff --git a/goga/assets/skills/goga-plan-by-design/output-template.md b/goga/assets/skills/goga-plan-by-design/output-template.md index fe5eea5e..71db6d40 100644 --- a/goga/assets/skills/goga-plan-by-design/output-template.md +++ b/goga/assets/skills/goga-plan-by-design/output-template.md @@ -1,12 +1,14 @@ # Plan Output Template Result of Phase 1 (structure) + Phase 2 (Usages calibration). -Saved to `docs/plans/<feature-name>.md`. +Saved to the path printed by `goga history path -f plan.md`. This format is compatible with ralphex execution. --- -# Plan: `<feature-name>` +# Plan: `<topic>` + +<!-- `<topic>` — the topic name (the topic directory under `.goga/history/<year>/<topic>/`) --> ## Purpose diff --git a/goga/assets/skills/goga-plan/SKILL.md b/goga/assets/skills/goga-plan/SKILL.md index deaddc91..cb390198 100644 --- a/goga/assets/skills/goga-plan/SKILL.md +++ b/goga/assets/skills/goga-plan/SKILL.md @@ -8,18 +8,8 @@ You are a technical planner specializing in contract-oriented implementation. Yo Arguments: $ARGUMENTS -Retain the original arguments for the entire session. - ### Design document identification -Determine `<function-name>`: - -1. **Arguments provided** — use them as the function name. -2. **Arguments empty** — scan the `docs/design/` directory: - - **Directory does not exist or is empty** — stop and ask the user to run `/goga:design` first. - - **Single file** — use its name (without extension) as `<function-name>`. - - **Multiple files** — display the list via AskUserQuestion and prompt the user to select one. - -Check if `docs/design/<function-name>.md` exists. -**Does not exist** — stop and ask the user to run `/goga:design` first. -**Exists** — call `goga-plan-by-design` via the **Skill tool** with `<function-name>` as the argument. +Check if the path printed by `goga history path -f design.md` exists: +- **Does not exist** — stop and ask the user to run `/goga:design` first. +- **Exists** — use the **Skill tool** to invoke `goga-plan-by-design` with the printed path as the argument. diff --git a/goga/assets/skills/goga-propose/SKILL.md b/goga/assets/skills/goga-propose/SKILL.md index 652d89d6..5d1fe09c 100644 --- a/goga/assets/skills/goga-propose/SKILL.md +++ b/goga/assets/skills/goga-propose/SKILL.md @@ -20,6 +20,6 @@ Use the **Skill tool** to invoke `goga-task-by-proposing` with the arguments as Arguments: $ARGUMENTS -The skill formulates the task and saves the artifact to `docs/tasks/<topic>.md`. +The skill formulates the task and saves the artifact at the path printed by `goga history path -f task.md` (it runs `goga history ensure` first if the topic directory does not exist). --- diff --git a/goga/assets/skills/goga-review-arch/SKILL.md b/goga/assets/skills/goga-review-arch/SKILL.md index 10ee82a6..2508d055 100644 --- a/goga/assets/skills/goga-review-arch/SKILL.md +++ b/goga/assets/skills/goga-review-arch/SKILL.md @@ -6,7 +6,7 @@ description: Review an architecture plan for semantic correctness ## Objective -Validate the architecture plan (`docs/arch/<topic>.md`) for **semantic correctness** — assess model cohesion, domain boundary +Validate the architecture plan (the file at the path printed by `goga history path -f arch.md`) for **semantic correctness** — assess model cohesion, domain boundary soundness, and requirement sufficiency for implementation. The agent **analyzes** the architecture plan, **reports** findings, and **applies fixes** when issues are detected (subject to user approval). @@ -22,8 +22,8 @@ all types, domains, and requirements as a unified whole — not each CODEMANIFES ## Input -- **Required**: architecture plan at `docs/arch/<topic>.md` -- **Optional**: task file at `docs/tasks/<topic>.md` — when present, used to verify requirements coverage +- **Required**: architecture plan at the path printed by `goga history path -f arch.md` +- **Optional**: task file at the path printed by `goga history path -f task.md` — when present, used to verify requirements coverage --- @@ -31,7 +31,7 @@ all types, domains, and requirements as a unified whole — not each CODEMANIFES ### Phase 1: Context Loading -1. Read the architecture plan from `docs/arch/<topic>.md` +1. Read the architecture plan from the path printed by `goga history path -f arch.md` 2. Load the DSL specification and DSL application principles: - Invoke `goga-cell` via **Skill tool** — to understand DSL rules (signature syntax, Import/Usage/Annotation rules, types, mutations, embeddings, constraints) @@ -49,7 +49,7 @@ all types, domains, and requirements as a unified whole — not each CODEMANIFES - Classify plan cells: newly created vs. modified 6. Read the existing CODEMANIFESTs of cells the plan marks for modification 7. Read the existing `.usages/` files of cells marked for modification -8. If the task file `docs/tasks/<topic>.md` exists — read it for subsequent requirements coverage verification +8. If the task file at the path printed by `goga history path -f task.md` exists — read it for subsequent requirements coverage verification --- @@ -233,7 +233,7 @@ Missing edge cases — log as **Medium**. #### Step 4. Task Requirements Coverage -If the task file `docs/tasks/<topic>.md` exists: +If the task file at the path printed by `goga history path -f task.md` exists: - Each requirement from the "Description" section must map to type(s) in the plan that fulfill it - Each acceptance criterion must have a contractual basis in the plan — the plan must enable fulfilling the criterion @@ -262,7 +262,7 @@ Modification breaks existing contracts — log as **Critical**. #### Step 2. Impact on Dependent Cells - Determine whether changes affect cells not mentioned in the plan but dependent on modified cells -- Run `--depends-on <cell_path>` to locate dependent cells +- Run `goga schema --depends-on <cell_path>` to locate dependent cells Unacknowledged affected cells — log as **High**. diff --git a/goga/assets/skills/goga-review-design/SKILL.md b/goga/assets/skills/goga-review-design/SKILL.md index 2e8c436b..749caca0 100644 --- a/goga/assets/skills/goga-review-design/SKILL.md +++ b/goga/assets/skills/goga-review-design/SKILL.md @@ -6,7 +6,7 @@ description: Design document verification via code stack tracing ## Purpose -Verifies the design document for **logical correctness** by tracing the full code stack for each entry point and test scenario. This is a **verification pass** — the goal is to find logical errors before plan creation. +Verifies the design document (the file at the path printed by `goga history path -f design.md`) for **logical correctness** by tracing the full code stack for each entry point and test scenario. This is a **verification pass** — the goal is to find logical errors before plan creation. You do **not** write implementation code. You **trace** each logical chain and **find** where the logic breaks. @@ -43,7 +43,7 @@ All CODEMANIFEST edits must be **proposed to the user** before applying. (document structure, signature syntax, Imports rules, Usages rules, Annotations rules, types, mutations, embeddings, constraints) - Use the **Skill tool** to invoke `goga-cookbook` — for understanding cell design principles and CODEMANIFEST (when to use Entity vs Routine, when to apply mutations and embeddings, usage file authoring principles, cell granularity) -2. Read the design document from `docs/design/<feature-name>.md` +2. Read the design document from the path printed by `goga history path -f design.md` 3. Read all relevant CODEMANIFEST files referenced by the design 4. Read existing source files referenced by the design (if any) 5. Execute `goga schema --help` to understand the command, then execute `goga schema` to obtain the full project dependency graph. Use `--depends-on <cell_path>` to discover cells that depend on cells modified by the design. This ensures the review covers all affected cells. diff --git a/goga/assets/skills/goga-review-plan/SKILL.md b/goga/assets/skills/goga-review-plan/SKILL.md index f23dc8d8..79e02b21 100644 --- a/goga/assets/skills/goga-review-plan/SKILL.md +++ b/goga/assets/skills/goga-review-plan/SKILL.md @@ -6,7 +6,7 @@ description: Verify execution plan completeness and correctness ## Objective -Verify the execution plan (`docs/plans/<feature-name>.md`) for **completeness and correctness** against the design document and `CODEMANIFEST` contracts before passing to ralphex. +Verify the execution plan (the file at the path printed by `goga history path -f plan.md`) for **completeness and correctness** against the design document and `CODEMANIFEST` contracts before passing to ralphex. You **verify** the plan, **report** findings, and **fix** the plan upon discovery of issues (subject to user approval). @@ -18,7 +18,7 @@ You **verify** the plan, **report** findings, and **fix** the plan upon discover ## Verifiable Artifact -- Plan file at `docs/plans/<feature-name>.md` — the execution plan, verified against sources of truth +- Plan file at the path printed by `goga history path -f plan.md` — the execution plan, verified against sources of truth --- @@ -30,8 +30,8 @@ You **verify** the plan, **report** findings, and **fix** the plan upon discover The language skill defines implementation conventions: cell structure, facade, signature rules, **naming**. Examples in other skills may use naming conventions of one language (e.g., snake_case), while the target language requires different conventions (e.g., PascalCase) — the language skill is the authoritative source for the target language. -2. Read the plan from `docs/plans/<feature-name>.md` -3. Read the design document from `docs/design/<feature-name>.md` +2. Read the plan from the path printed by `goga history path -f plan.md` +3. Read the design document from the path printed by `goga history path -f design.md` 4. Read all relevant `CODEMANIFEST` files referenced by the design document 5. Load the DSL specification and DSL application principles: - Invoke `goga-cell` via the **Skill tool** — obtain the DSL reference @@ -228,7 +228,7 @@ Use AskUserQuestion with options: #### Step 3. Apply the Decision -- **Apply suggested fix**: update the plan file at `docs/plans/<feature-name>.md`, then re-verify that the fix introduces no new issues (re-run the relevant checks). Briefly report the re-verification result. +- **Apply suggested fix**: update the plan file at the path printed by `goga history path -f plan.md`, then re-verify that the fix introduces no new issues (re-run the relevant checks). Briefly report the re-verification result. - **Skip**: record the finding as "skipped" and proceed. - **Suggest alternative**: discuss the alternative with the user, agree on a fix, apply it, and re-verify. diff --git a/goga/assets/skills/goga-review-task/SKILL.md b/goga/assets/skills/goga-review-task/SKILL.md index 0ffe58cc..7f9a5cee 100644 --- a/goga/assets/skills/goga-review-task/SKILL.md +++ b/goga/assets/skills/goga-review-task/SKILL.md @@ -6,7 +6,7 @@ description: Review a task for completeness, correctness, and consistency ## Objective -Validates a task (`docs/tasks/<topic>.md`) for **completeness, correctness, and consistency** — ensuring the task is formulated clearly enough to proceed to architecture (`goga-brainstorm`). +Validates a task (the file at the path printed by `goga history path -f task.md`) for **completeness, correctness, and consistency** — ensuring the task is formulated clearly enough to proceed to architecture (`goga-brainstorm`). You **verify** the task, **report** findings, and **fix** the task when issues are discovered (with user approval). @@ -24,7 +24,7 @@ You **verify** the task, **report** findings, and **fix** the task when issues a ## Verifiable Artifact -- Task file at `docs/tasks/<topic>.md` — a formulated task being verified for completeness and correctness +- Task file at the path printed by `goga history path -f task.md` — a formulated task being verified for completeness and correctness --- @@ -32,7 +32,7 @@ You **verify** the task, **report** findings, and **fix** the task when issues a ### Phase 1: Load Context -1. Read the task from `docs/tasks/<topic>.md` +1. Read the task from the path printed by `goga history path -f task.md` 2. Load the DSL specification and DSL application principles: - Use the **Skill tool** to invoke `goga-cell` — for understanding cell terminology and CODEMANIFEST when verifying the "Existing Architecture" section - Use the **Skill tool** to invoke `goga-cookbook` — for understanding cell interaction principles when verifying the correctness of affected cells description diff --git a/goga/assets/skills/goga-review/SKILL.md b/goga/assets/skills/goga-review/SKILL.md index 8cbdc01e..02487ddb 100644 --- a/goga/assets/skills/goga-review/SKILL.md +++ b/goga/assets/skills/goga-review/SKILL.md @@ -10,43 +10,50 @@ Arguments: $ARGUMENTS ### Review Type Detection -1. **Arguments contain a path** — derive the review type by matching path segments: - - Path contains `docs/arch/` → **architecture** - - Path contains `docs/design/` → **design** - - Path contains `docs/plans/` → **plan** - - Path contains `docs/tasks/` → **task** - - Path does not contain `docs/` (or points to another location) → **cell** - - Extract `<target>` from the path: - - For `docs/arch/javascript-contract.md` → `<target>` = `javascript-contract` +1. **Arguments contain a path under the path printed by `goga history path -f <kind>.md`** : + - Derive the review type by `<kind>` (the filename without `.md`): + - `prd.md` → **prd** + - `adr.md` → **adr** + - `task.md` → **task** + - `arch.md` → **architecture** + - `design.md` → **design** + - `plan.md` → **plan** + - Any other filename, or a path outside `.goga/history/` → **cell**. + + Extract `<target>` (the topic): + - For `.goga/history/<year>/<topic>/<kind>.md` → `<target>` = `<topic>` - For `src/cell/my-cell` → `<target>` = `src/cell/my-cell` - For `my-cell` → `<target>` = `my-cell` -2. **Arguments are empty** — prompt the user via AskUserQuestion: +2. **Arguments are empty** — ask the user: - **question**: "What do you want to review?" - **header**: "Review type" - **multiSelect**: false - **options**: - - **label**: "architecture", **description**: "Review an architecture plan from docs/arch/" - - **label**: "design", **description**: "Review a design document from docs/design/" - - **label**: "plan", **description**: "Review an implementation plan from docs/plans/" + - **label**: "architecture", **description**: "Review an architecture plan from .goga/history/" + - **label**: "design", **description**: "Review a design document from .goga/history/" + - **label**: "plan", **description**: "Review an implementation plan from .goga/history/" - **label**: "cell", **description**: "Review a cell (CODEMANIFEST and file structure)" - - **label**: "task", **description**: "Review a task from docs/tasks/" + - **label**: "task", **description**: "Review a task from .goga/history/" ### Type-Based Routing +#### prd / adr +There is no review skill for this artifact kind yet. +1. Stop execution and report to the user that PRD/ADR review is not supported. + #### architecture -Verify that `docs/arch/<target>.md` exists. +Verify the path printed by `goga history path -f arch.md` exists. 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-arch` via the **Skill tool**, passing `<target>` as the argument. #### design -Verify that `docs/design/<target>.md` exists. +Verify the path printed by `goga history path -f design.md` exists. 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-design` via the **Skill tool**, passing `<target>` as the argument. #### plan -Verify that `docs/plans/<target>.md` exists. +Verify the path printed by `goga history path -f plan.md` exists. 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-plan` via the **Skill tool**, passing `<target>` as the argument. @@ -56,6 +63,6 @@ Verify that directory `<target>` and file `<target>/CODEMANIFEST` both exist. 2. **Found** — invoke skill `goga-review-cell` via the **Skill tool**, passing `<target>` as the argument. #### task -Verify that `docs/tasks/<target>.md` exists. +Verify the path printed by `goga history path -f task.md` exists. 1. **Not found** — stop execution and report to the user. 2. **Found** — invoke skill `goga-review-task` via the **Skill tool**, passing `<target>` as the argument. diff --git a/goga/assets/skills/goga-task-by-proposing/SKILL.md b/goga/assets/skills/goga-task-by-proposing/SKILL.md index 12ce3ac4..f28b6cd9 100644 --- a/goga/assets/skills/goga-task-by-proposing/SKILL.md +++ b/goga/assets/skills/goga-task-by-proposing/SKILL.md @@ -7,7 +7,8 @@ description: Interactive task formulation from a raw request ## Purpose Transforms a raw user request (e.g., "add authorization") into a **formulated task** — a structured document containing -the description, technology stack, dependencies, and scope estimate. The output is persisted to `docs/tasks/<topic>.md` +the description, technology stack, dependencies, and scope estimate. The output is persisted at the path printed by +`goga history path -f task.md` (run `goga history ensure` first if the topic directory does not exist) and serves as input for the `goga-brainstorm` skill. --- @@ -164,9 +165,9 @@ If all external dependencies are covered by current usage files, skip this phase ### Phase 7: Task Persistence -**Objective:** Save the formulated task to `docs/tasks/<topic>.md` using the template. +**Objective:** Save the formulated task to the path printed by `goga history path -f task.md`, using the template (run `goga history ensure` first if the topic directory does not exist). -`<topic>` is a short name derived from the task topic (from the user's Phase 1 description). +The topic directory is the current one in the history tree, resolved by `goga history path`. 1. Read the `task-template.md` template from the current skill directory and apply its structure. diff --git a/goga/build/.usages/build-usage.md b/goga/build/.usages/build-usage.md index 5bcb553d..30b276ca 100644 --- a/goga/build/.usages/build-usage.md +++ b/goga/build/.usages/build-usage.md @@ -27,6 +27,7 @@ exit_code = build( "worktree": True, "skip_finalize": False, "skip_manifest_check": False, + "base_ref": "origin/1.2.x", # review diff base (review-scoped) }, ) ``` @@ -36,7 +37,8 @@ exit_code = build( - `plan` — path to the plan file (markdown) - `config` — ProjectConfig object loaded via `load_project_config` - `cli_options` — options dictionary (dry_run, worktree, skip_finalize, skip_manifest_check, - skip_review, session_timeout, idle_timeout, wait, max_iterations, review_patience) + skip_review, session_timeout, idle_timeout, wait, max_iterations, review_patience, + base_ref) ## Review-phase control @@ -59,6 +61,24 @@ runs. With skip: true the review env is ignored entirely. build.review_executor.roles filters {{agent:X}} lines in both review prompts; empty list or absent = full default set; files of all 5 agents are always present in .ralphex/agents/. +### Review-scoped options + +`base_ref` (review diff base — branch name or commit hash) and `patience` +(external-review stop threshold) are review-scoped: they resolve with +precedence CLI > `build.review_executor.*` > omit and join the ralphex +options of review-carrying passes only — the full-mode single pass and the +two-pass review pass. A skipped run and the tasks-only pass never carry +them. + +cli_options={'base_ref': 'origin/1.2.x'} or .goga/config.yml +build.review_executor.base_ref: origin/1.2.x → ralphex receives +--base-ref origin/1.2.x on the review-carrying pass. The same precedence +holds for the review_patience cli_options key / +build.review_executor.patience → --review-patience. + +When neither source sets them, the keys stay absent and the assembled +ralphex command carries no extra flags. + ## Review-pass environment build.review_executor.env (mapping of strings) overrides same-named variables @@ -103,4 +123,4 @@ prepares the mount before launch and wipes it only on `goga build --clean`. The ## Docker entry point -`main()` calls `ensure_in_docker()` first, then argparse handles parsing and calls `build()`. +`main()` calls `ensure_in_docker()` first, then argparse handles parsing (including `--base-ref`) and calls `build()`. diff --git a/goga/build/CODEMANIFEST b/goga/build/CODEMANIFEST index b22803d6..2a66ff19 100644 --- a/goga/build/CODEMANIFEST +++ b/goga/build/CODEMANIFEST @@ -41,6 +41,14 @@ Annotations: | goga/ralphex (per the `run-ralphex` practice) — ralphex is launched through `run_ralphex`, never directly from this cell. + Option resolution follows two zones: universal options (worktree, + skip_finalize, session_timeout, idle_timeout, wait, max_iterations) + resolve with precedence CLI > `BuildConfig` > omit and apply to every + ralphex pass; review-scoped options (base_ref, patience) resolve with + precedence CLI > `ReviewExecutorConfig` > omit inside `resolve_review_options` + and apply only to review-carrying passes (the full-mode single pass and the + two-pass review pass). + This cell also owns the review-phase orchestration of the build: tri-state skip resolution, review-env handling (two-pass induction by a non-empty review env, the env-requires-agent gate, the per-pass env layer of the @@ -72,7 +80,8 @@ Annotations: | `config`: loaded project configuration object `cli_options`: dictionary of CLI options (dry_run, worktree, skip_finalize, skip_manifest_check, skip_review, session_timeout, - idle_timeout, wait, max_iterations, review_patience) + idle_timeout, wait, max_iterations, review_patience, + base_ref) `exit_code`: process exit code (0 = success, 1 = failure) Algorithm: @@ -83,18 +92,29 @@ Annotations: | agent field of `TaskExecutorConfig`, per the `resolve-wrapper-path` practice (absolute in-container path /home/goga/bin/<name>-as-claude.sh per `agent-wrappers`) 2. Resolve the review options (skip, review agent, roles, review env, - two-pass mode) via `resolve_review_options` + two-pass mode, review-scoped options base_ref and patience) via + `resolve_review_options` 3. Validate the review configuration via `validate_review_config` when the review phase will run 4. Write .ralphex/config for the first pass via `write_ralphex_config` 5. Fully rewrite .ralphex/prompts/ and .ralphex/agents/ from the vendored defaults via `sync_ralphex_defaults`, applying the declared roles to the review prompts - 6. Resolve the ralphex options with precedence CLI options > `BuildConfig` > omit, - producing the resolved options for `run_ralphex` + 6. Resolve the universal ralphex options with precedence CLI options > + `BuildConfig` > omit — worktree, skip_finalize, session_timeout, + idle_timeout, wait, max_iterations — producing the universal options for + `run_ralphex`; review-scoped options are NOT resolved here (their owner + is `resolve_review_options` of step 2) 7. Launch each planned pass via `run_build_pass` (which delegates the launch to `run_ralphex`), forwarding the dry_run flag of `cli_options` so a dry - run prints the commands of every planned pass instead of launching: + run prints the commands of every planned pass instead of launching; + compose the options of each pass: + - universal options join the options of every pass + - review-scoped options — base_ref (options key base_ref) and patience + (options key review_patience) of `ReviewOptions` — join the options of + review-carrying passes ONLY: the full-mode single pass and the two-pass + review pass; a skip run and the tasks-only pass carry universal + options only - skip run: one pass with tasks_only — the review env is ignored entirely; other phases unchanged - two-pass run: pass 1 with tasks_only and the task wrapper, no env @@ -130,12 +150,19 @@ Annotations: | in place - On dry-run print the commands of every planned pass without the env layer contents — the review env never reaches logs or dry-run output + - Review-scoped options never appear in the options of a skip run or the + tasks-only pass + - When neither the CLI nor the config sets base_ref/patience, the keys stay + absent from the options — the assembled ralphex command carries no + --base-ref / --review-patience flags Constraints: - Wrappers live in the image at /home/goga/bin/ and are referenced by absolute path - Agent resolution is uniform — do not branch by agent name - Do not assemble the ralphex command or invoke ralphex directly — delegate to `run_ralphex` - The .ralphex/ directory lifecycle is owned by the host launcher (goga/commands/build) + - Do not resolve review-scoped option precedence in the universal resolution + step — the owner is `resolve_review_options` "main() -> exit_code:int": location: __main__.py @@ -150,10 +177,11 @@ Annotations: | `ensure-in-docker` practice) 1. Parse CLI arguments via argparse (plan + options); the argparse surface carries the --skip-review / --no-skip-review pair resolving to - skip_review: bool | None (None when neither flag is given) + skip_review: bool | None (None when neither flag is given) and the + --base-ref option (type str, default None — the review diff base) 2. Load project configuration via `load_project_config` 3. Build cli_options from the parsed argparse results; cli_options carries - the skip_review key + the skip_review key and the base_ref key 4. Invoke `build`(plan, `ProjectConfig`, cli_options) 5. Return the resulting `exit_code` @@ -172,7 +200,10 @@ Annotations: | value and the project configuration. `config`: build configuration (`BuildConfig`) with the optional review_executor sub-configuration - `cli_options`: CLI options dictionary; the skip_review key is bool | None (None = flag not given) + `cli_options`: CLI options dictionary; the keys read here are skip_review + (bool | None — None = flag not given), base_ref (str | None — an empty + or whitespace-only value counts as unset), and review_patience + (int | None) `review`: resolved review options (`ReviewOptions`) Algorithm: @@ -186,17 +217,32 @@ Annotations: | 5. Take the review env verbatim into review_env (an empty dict stays empty; a review env equal to task_executor.env is still non-empty and keeps two_pass true) + 6. Resolve base_ref: take the cli_options base_ref when not None, otherwise + the base_ref field of `ReviewExecutorConfig`, otherwise None; an empty + or whitespace-only value from either source resolves as unset (None); + a padded value (non-empty with surrounding whitespace) resolves to its + stripped form — the CLI path and directly-constructed configs are not + loader-normalized + 7. Resolve patience: take the cli_options review_patience when not None, + otherwise the patience field of `ReviewExecutorConfig`, otherwise None Requirements: - Precedence CLI > ProjectConfig > omit - An empty roles list reaches the consumers as an empty list - A non-empty review env induces two_pass regardless of dictionary equality with task_executor.env + - Precedence for base_ref and patience: CLI > build.review_executor.* > omit + - An absent review_executor section leaves base_ref and patience None (like + the other review fields) + - The code docstring of `resolve_review_options` lists the same three + cli_options keys — skip_review, base_ref, and review_patience Constraints: - Pure — no side effects, no validation of values (separate routine) + - The resolved base_ref is never checked for resolvability or format — + diagnostics of the review diff base belong to ralphex -"ReviewOptions(skip: bool, review_agent: str | None, roles: list[str] | None, two_pass: bool, review_env: dict[str, str])": +"ReviewOptions(skip: bool, review_agent: str | None, roles: list[str] | None, two_pass: bool, review_env: dict[str, str], base_ref: str | None, patience: int | None)": location: review_options.py annotations: | Resolved review-phase execution plan of a single build. @@ -209,6 +255,10 @@ Annotations: | `review_env`: review-pass environment layer, verbatim from `ReviewExecutorConfig` (an empty dict when unset); forwarded as the env layer of the review pass by the orchestrator + `base_ref`: resolved review diff base (branch name or commit hash), None + when unset; forwarded to review-carrying passes only + `patience`: resolved external-review stop threshold, None when unset; + forwarded to review-carrying passes only Requirements: - Immutable frozen dataclass (frozen=True, kw_only=True), per `conventions` @@ -227,6 +277,18 @@ Annotations: | Review-pass environment layer, verbatim; an empty dict when the configuration declares no env. The layer overlays the container environment on the review-pass subprocess only. + "base_ref -> str | None": | + Resolved review diff base — branch name or commit hash, verbatim. + None when neither the CLI option nor the build.review_executor.base_ref + config field declares one (an empty or whitespace-only value counts as + unset). Consumed by the pass composition: forwarded to `run_ralphex` as + the base_ref options key on review-carrying passes only. + "patience -> int | None": | + Resolved external-review stop threshold — stop the external review + after N consecutive unchanged rounds. None when neither the CLI option + nor the build.review_executor.patience config field declares one. + Forwarded as the review_patience options key on review-carrying passes + only. "validate_review_config(config: BuildConfig, review: ReviewOptions) -> none: None": location: review_config.py diff --git a/goga/build/__main__.py b/goga/build/__main__.py index bfbfbf58..a7a61dcc 100644 --- a/goga/build/__main__.py +++ b/goga/build/__main__.py @@ -29,6 +29,7 @@ def main() -> int: parser.add_argument("--wait", type=str, default=None) parser.add_argument("--max-iterations", type=int, default=None) parser.add_argument("--review-patience", type=int, default=None) + parser.add_argument("--base-ref", type=str, default=None) args = parser.parse_args() config = load_project_config() @@ -43,6 +44,7 @@ def main() -> int: "wait": args.wait, "max_iterations": args.max_iterations, "review_patience": args.review_patience, + "base_ref": args.base_ref, "dry_run": args.dry_run, } diff --git a/goga/build/build.py b/goga/build/build.py index 215dc733..e588880a 100644 --- a/goga/build/build.py +++ b/goga/build/build.py @@ -10,7 +10,7 @@ from .plan_relocation import move_completed_plan from .ralphex_runtime import sync_ralphex_defaults from .review_config import validate_review_config -from .review_options import resolve_review_options +from .review_options import ReviewOptions, resolve_review_options logger = logging.getLogger(__name__) @@ -59,7 +59,7 @@ def _find_uncommitted_manifests() -> list[str]: def _resolve_options(config: ProjectConfig, cli_options: dict) -> dict[str, str | int | bool]: - """Resolve ralphex options with precedence CLI > BuildConfig > omit. + """Resolve the universal ralphex options with precedence CLI > BuildConfig > omit. Applies the precedence HERE in the build domain so `run_ralphex` performs no resolution. For store_true bool keys, a CLI value of False is treated as @@ -68,6 +68,13 @@ def _resolve_options(config: ProjectConfig, cli_options: dict) -> dict[str, str CLI value wins when present (not None) and otherwise falls back to BuildConfig. This helper knows no ralphex flag names; `run_ralphex` maps the resolved keys. + Only the universal options (worktree, skip_finalize, session_timeout, + idle_timeout, wait, max_iterations) are resolved here — they apply to every + ralphex pass. The review-scoped keys (`review_patience`, `base_ref`) are NOT + resolved here: their owner is `resolve_review_options`, which applies the + precedence CLI > `ReviewExecutorConfig` > omit, and the orchestrator joins + them onto review-carrying passes only. + The pass-mode keys `tasks_only`/`review` are deliberately NOT resolved here — they are mode flags of a single pass, laid on top of the base options by the orchestrator with a dict copy, never read from config or CLI passthrough. @@ -84,13 +91,40 @@ def _resolve_options(config: ProjectConfig, cli_options: dict) -> dict[str, str for key in ("worktree", "skip_finalize"): resolved[key] = bool(cli_options.get(key) or getattr(config.build, key)) - for key in ("session_timeout", "idle_timeout", "wait", "max_iterations", "review_patience"): + for key in ("session_timeout", "idle_timeout", "wait", "max_iterations"): cli_value = cli_options.get(key) resolved[key] = cli_value if cli_value is not None else getattr(config.build, key) return resolved +def _review_scoped_options(review: ReviewOptions) -> dict[str, str | int]: + """Project the review-scoped fields of a resolved ReviewOptions into an options fragment. + + The inverse end of the naming split fixed by the contract: the options key + for the diff base is `base_ref` (the ralphex option name), the key for the + stop threshold re-expands to `review_patience`. Both keys are ABSENT from + the fragment when unset — never present-with-None — so an unset source + yields an empty dict and the composed pass options stay byte-identical to + a run that never declared review bounds. + + Args: + review: Resolved review options of the run (already precedence-reduced + and whitespace-normalized by `resolve_review_options`). + + Returns: + The review-scoped options fragment joined onto review-carrying passes. + """ + options: dict[str, str | int] = {} + + if review.base_ref is not None: + options["base_ref"] = review.base_ref + if review.patience is not None: + options["review_patience"] = review.patience + + return options + + def build(plan: str, config: ProjectConfig, cli_options: dict) -> int: """Execute the build pipeline for a plan, orchestrating its review phase. @@ -108,7 +142,10 @@ def build(plan: str, config: ProjectConfig, cli_options: dict) -> int: first — without an env layer — and, when it succeeds, a review-only pass with the review wrapper and the review env as its environment layer; a pass-1 failure exits with its code and skips pass 2 (and its env layer); - anything else is one full pass, without a layer. + anything else is one full pass, without a layer. Review-scoped options + (base_ref, review_patience) join the options of review-carrying passes + only — the full-mode single pass and the two-pass review pass; a skip run + and the tasks-only pass carry universal options only. Args: plan: Path to the build plan file. @@ -134,6 +171,8 @@ def build(plan: str, config: ProjectConfig, cli_options: dict) -> int: review = resolve_review_options(config.build, cli_options) + review_scoped = _review_scoped_options(review) + if not review.skip: try: validate_review_config(config.build, review) @@ -159,10 +198,15 @@ def build(plan: str, config: ProjectConfig, cli_options: dict) -> int: if exit_code == 0: exit_code = run_build_pass( - plan, config.build, {**base, "review": True}, review_wrapper, dry_run, env=review.review_env + plan, + config.build, + {**base, "review": True, **review_scoped}, + review_wrapper, + dry_run, + env=review.review_env, ) else: - exit_code = run_build_pass(plan, config.build, base, task_wrapper, dry_run) + exit_code = run_build_pass(plan, config.build, {**base, **review_scoped}, task_wrapper, dry_run) move_completed_plan(plan, outcome=(exit_code == 0), dry_run=dry_run) diff --git a/goga/build/review_options.py b/goga/build/review_options.py index b0657c64..940ceb17 100644 --- a/goga/build/review_options.py +++ b/goga/build/review_options.py @@ -15,8 +15,12 @@ class ReviewOptions: `two_pass` is True when a review executor agent is set and either differs from the task executor agent or carries a non-empty review env; `review_env` is the review-pass environment layer, verbatim (an empty dict - when the section declares no env). Branch priority between `skip` and - `two_pass` belongs to the orchestrator, not to this value-object. + when the section declares no env); `base_ref` is the resolved review diff + base (None when unset, whitespace-normalized by the resolver); `patience` + is the resolved external-review stop threshold (None when unset). The two + review-scoped fields are forwarded to review-carrying passes only. Branch + priority between `skip` and `two_pass` belongs to the orchestrator, not to + this value-object. """ skip: bool @@ -24,16 +28,18 @@ class ReviewOptions: roles: list[str] | None two_pass: bool review_env: dict[str, str] + base_ref: str | None = None + patience: int | None = None def resolve_review_options(config: BuildConfig, cli_options: dict) -> ReviewOptions: """Reduce the tri-state skip flag and the review executor section to a decision. - Pure function — no I/O, no validation, no normalization. Precedence for - `skip` is CLI > ProjectConfig > omit: a non-None `cli_options["skip_review"]` - wins, otherwise `build.review_executor.skip` (when the section exists), - otherwise False. An empty roles list travels to the consumer as an empty - list (the "full default set" reading belongs to the consumer). + Pure function — no I/O, no validation of values. Precedence for `skip` is + CLI > ProjectConfig > omit: a non-None `cli_options["skip_review"]` wins, + otherwise `build.review_executor.skip` (when the section exists), otherwise + False. An empty roles list travels to the consumer as an empty list (the + "full default set" reading belongs to the consumer). `two_pass` is True when a review agent is set and it differs from the task executor agent OR the review env is non-empty (dictionary equality with @@ -41,9 +47,28 @@ def resolve_review_options(config: BuildConfig, cli_options: dict) -> ReviewOpti `review_env` is `build.review_executor.env` verbatim (shared by reference, like `TaskExecutorConfig.env`) — an empty dict when there is no section. + The review-scoped options resolve with the precedence CLI > + `build.review_executor.*` > omit: `base_ref` takes + `cli_options["base_ref"]` when set, otherwise + `build.review_executor.base_ref`; an empty or whitespace-only value from + either source counts as unset (resolved to None; a padded value resolves + to its stripped form — the CLI path and directly-constructed configs are + not loader-normalized). `patience` takes `cli_options["review_patience"]` + when set, otherwise `build.review_executor.patience`. An absent + review_executor section leaves both None. The resolved `base_ref` is never + checked for resolvability or format — diagnostics of the review diff base + belong to ralphex. + Args: config: Build configuration with the optional review_executor section. - cli_options: In-container CLI options; only `skip_review` is read. + cli_options: In-container CLI options; the keys read here are + `skip_review` (bool | None — None = flag not given), `base_ref` + (str | None — an empty or whitespace-only value counts as unset), + and `review_patience` (int | None). + + Returns: + The resolved ReviewOptions: the skip decision, review agent, roles, + env, and two_pass flag, plus the review-scoped base_ref and patience. """ review_executor = config.review_executor @@ -61,10 +86,22 @@ def resolve_review_options(config: BuildConfig, cli_options: dict) -> ReviewOpti review_env = review_executor.env if review_executor is not None else {} two_pass = review_agent is not None and (review_agent != config.task_executor.agent or bool(review_env)) + base_ref = cli_options.get("base_ref") + if base_ref is None and review_executor is not None: + base_ref = review_executor.base_ref + if base_ref is not None: + base_ref = base_ref.strip() or None + + patience = cli_options.get("review_patience") + if patience is None and review_executor is not None: + patience = review_executor.patience + return ReviewOptions( skip=skip, review_agent=review_agent, roles=roles, two_pass=two_pass, review_env=review_env, + base_ref=base_ref, + patience=patience, ) diff --git a/goga/cli.py b/goga/cli.py index 2a30db5e..5668026b 100644 --- a/goga/cli.py +++ b/goga/cli.py @@ -9,12 +9,15 @@ config, connect, contract, + history, + hooks, init, install, lint, pipeline, schema, tool, + topics, uninstall, upgrade, usages, @@ -74,3 +77,6 @@ def app() -> None: app.add_command(usages) app.add_command(tool) app.add_command(upgrade) +app.add_command(history) +app.add_command(hooks) +app.add_command(topics) diff --git a/goga/commands/.usages/cli-commands.md b/goga/commands/.usages/cli-commands.md index 1017028d..b3b599e4 100644 --- a/goga/commands/.usages/cli-commands.md +++ b/goga/commands/.usages/cli-commands.md @@ -1,6 +1,6 @@ # CLI Commands — goga/commands facade -The `goga.commands` package is a facade that re-exports 13 CLI commands. Each command is a `click.Command` registered in a click group. Each subcell is an independent Python package (`goga/commands/<name>/`) with implementation in `<name>.py` and re-export through `__init__.py`. +The `goga.commands` package is a facade that re-exports 16 CLI commands. Each command is a `click.Command` registered in a click group. Each subcell is an independent Python package (`goga/commands/<name>/`) with implementation in `<name>.py` and re-export through `__init__.py`. ## Import @@ -21,6 +21,9 @@ from goga.commands import ( upgrade, install, uninstall, + history, + hooks, + topics, ) ``` @@ -40,6 +43,9 @@ from goga.commands.pipeline import pipeline from goga.commands.upgrade import upgrade from goga.commands.install import install from goga.commands.install import uninstall +from goga.commands.history import history +from goga.commands.hooks import hooks +from goga.commands.topics import topics ``` ## Registration in click group @@ -61,6 +67,9 @@ from goga.commands import ( upgrade, install, uninstall, + history, + hooks, + topics, ) @@ -82,6 +91,9 @@ app.add_command(pipeline) app.add_command(upgrade) app.add_command(install) app.add_command(uninstall) +app.add_command(history) +app.add_command(hooks) +app.add_command(topics) ``` ## Testing with CliRunner @@ -114,3 +126,6 @@ def test_example(): | `upgrade` | `goga/commands/upgrade/` | Upgrade goga and re-sync agents | | `install` | `goga/commands/install/` | Install a goga_tool_* package | | `uninstall` | `goga/commands/install/` | Remove a goga_tool_* package | +| `history` | `goga/commands/history/` | Work with the .goga/history/ tree | +| `hooks` | `goga/commands/hooks/` | Inspect registered tool hooks | +| `topics` | `goga/commands/topics/` | Topic board, creation, and switching | diff --git a/goga/commands/CODEMANIFEST b/goga/commands/CODEMANIFEST index 031bce18..9192f45a 100644 --- a/goga/commands/CODEMANIFEST +++ b/goga/commands/CODEMANIFEST @@ -28,6 +28,8 @@ Imports: From: goga/commands/init - Types: - pipeline + Usages: + - pipeline-command From: goga/commands/pipeline - Types: - upgrade @@ -37,7 +39,23 @@ Imports: - uninstall Usages: - uninstall-usage + - install AS install-usage From: goga/commands/install + - Types: + - history + Usages: + - history-command + From: goga/commands/history + - Types: + - topics + Usages: + - topics-command + From: goga/commands/topics + - Types: + - hooks + Usages: + - hooks-command + From: goga/commands/hooks Usages: convention: .goga/usages/conventions.md @@ -54,6 +72,23 @@ Annotations: | command: confirmation behavior, flags, exit codes, and the post-removal re-sync. + Use the `pipeline-command` practice for consumer scenarios of the + pipeline command: the five forms, the topic switch flow of -t/--topic + with --todo, and the flag behavior per form. + Use the `install-usage` practice for consumer scenarios of the install + command: the four modes, the post-install hooks, and the exit codes. + + Use the `history-command` practice for consumer scenarios of the history + command group: the five subcommands, their options and filters, the path + and ensure behaviors, and the exit codes. + + Use the `topics-command` practice for consumer scenarios of the topics + command group: the board table, the creation flow, the switching flow, + the deletion flow, and the exit codes. + + Use the `hooks-command` practice for consumer scenarios of the hooks + command: the registry tree, the tool slice, and the exit codes. + --- ->lint: {} @@ -69,6 +104,9 @@ Annotations: | ->upgrade: {} ->install: {} ->uninstall: {} +->history: {} +->topics: {} +->hooks: {} --- diff --git a/goga/commands/__init__.py b/goga/commands/__init__.py index 44309afb..992220b2 100644 --- a/goga/commands/__init__.py +++ b/goga/commands/__init__.py @@ -2,12 +2,15 @@ from .config import config from .connect import connect from .contract import contract +from .history import history +from .hooks import hooks from .init import init from .install import install, uninstall from .lint import lint from .pipeline import pipeline from .schema import schema from .tool import tool +from .topics import topics from .upgrade import upgrade from .usages import usages @@ -16,12 +19,15 @@ "config", "connect", "contract", + "history", + "hooks", "init", "install", "lint", "pipeline", "schema", "tool", + "topics", "uninstall", "upgrade", "usages", diff --git a/goga/commands/build/.usages/build.md b/goga/commands/build/.usages/build.md index 088fe421..6849f774 100644 --- a/goga/commands/build/.usages/build.md +++ b/goga/commands/build/.usages/build.md @@ -9,7 +9,7 @@ CLI wrapper for the build command. Parses click options, loads configuration, an ``` goga build <plan> [--dry-run] [--worktree] [--skip-finalize] [--skip-manifest-check] [--session-timeout T] [--idle-timeout T] [--wait T] - [--max-iterations N] [--review-patience N] + [--max-iterations N] [--review-patience N] [--base-ref REF] [--skip-review | --no-skip-review] [-e KEY=VALUE ...] [--proxy URL] [--add-host HOST:IP ...] [--clean] [--update | -u] @@ -34,6 +34,7 @@ goga build <plan> [--dry-run] [--worktree] [--skip-finalize] [--skip-manifest-ch | `--wait` | str | from config | Wait on rate limit | | `--max-iterations` | int | from config | Maximum iterations | | `--review-patience` | int | from config | Review stop threshold | +| `--base-ref` | str | from config | Review diff base (branch name or commit hash). Overrides `build.review_executor.base_ref` in `.goga/config.yml`; forwarded to the container only when set. Reaches ralphex as `--base-ref` on the review-carrying pass only | | `--skip-review` / `--no-skip-review` | bool pair | tri-state | Skip the review phase (`--skip-review`) or force the full cycle (`--no-skip-review`). Overrides `build.review_executor.skip` in `.goga/config.yml`; when neither flag is given, the config decides | | `-e` / `--env` | str (multiple) | — | Pass environment variables to the container (KEY=VALUE) | | `--proxy` | str | from config | HTTP/HTTPS proxy URL; overrides `build.proxy` in `.goga/config.yml`. When set, adds HTTP_PROXY/HTTPS_PROXY/NO_PROXY to the container env-file | @@ -64,6 +65,9 @@ goga build docs/plans/my-plan.md # Route container traffic through a corporate proxy and add a local host entry goga build docs/plans/my-plan.md --proxy http://corp:3128 --add-host foo.local:127.0.0.1 +# Scope the review diff to a release branch base +goga build docs/plans/my-plan.md --base-ref origin/1.2.x + # Wipe ralphex state before launch (start fresh) goga build docs/plans/my-plan.md --clean @@ -99,6 +103,10 @@ A differing build.review_executor.agent OR a non-empty build.review_executor.env (the review pass cannot follow the worktree branch). The guard is config-level and skip-independent — the host does not resolve the tri-state --skip-review. +`--base-ref` follows the same forwarding discipline: the host does not +resolve it against config — an unset flag leaves the decision to +`build.review_executor.base_ref` in-container. + ## Proxy and hosts `--proxy URL` (and `build.proxy` in config) drive three env-file entries when set: diff --git a/goga/commands/build/CODEMANIFEST b/goga/commands/build/CODEMANIFEST index 3d23100a..a2f65b25 100644 --- a/goga/commands/build/CODEMANIFEST +++ b/goga/commands/build/CODEMANIFEST @@ -138,6 +138,10 @@ Annotations: | - --dry-run, --worktree, --skip-finalize, --skip-manifest-check - --session-timeout, --idle-timeout, --wait - --max-iterations, --review-patience + - --base-ref REF (str) — review diff base (branch name or commit hash); + overrides build.review_executor.base_ref; forwarded to the container + and applied to the review-carrying pass only (per the `build-usage` + practice) - --skip-review / --no-skip-review (bool pair) — skip the review phase; tri-state, overrides build.review_executor.skip - -e / --env KEY=VALUE (multiple) — pass environment variables to the container @@ -191,7 +195,10 @@ Annotations: | in-container orchestrator). 3. Collect cli_flags from click parameters; forward the review pair by the --worktree pattern: skip_review True → --skip-review; False → - --no-skip-review; None → neither flag (the tri-state survives to the container) + --no-skip-review; None → neither flag (the tri-state survives to the + container); forward --base-ref by the existing value-option pattern — + the flag with its value joins cli_flags only when the option is set; + unset (None) adds no flag, leaving the decision to the container config 4. Resolve the proxy: take `proxy` when not None, otherwise fall back to config.build.proxy 5. Resolve hosts: merge config.build.hosts with parsed `add_host` entries; @@ -326,6 +333,9 @@ Annotations: | - The env-induced two-pass × worktree conflict is the same host-side guard as the differing-agent one — it fires before docker run, before any filesystem or container work, regardless of --skip-review + - The host does NOT resolve --base-ref precedence against config — + forwarding only; the unset case resolves in-container (CLI > + build.review_executor.base_ref > omit) Constraints: - Do not refresh an EXISTING image by default — refresh only when `update` diff --git a/goga/commands/build/build.py b/goga/commands/build/build.py index 5356a474..9262a0aa 100644 --- a/goga/commands/build/build.py +++ b/goga/commands/build/build.py @@ -131,7 +131,7 @@ def _cli_flags_to_args(cli_flags: dict[str, bool | str | int | None]) -> list[st elif sr is False: args.append("--no-skip-review") - for flag in ("session_timeout", "idle_timeout", "wait", "max_iterations", "review_patience"): + for flag in ("session_timeout", "idle_timeout", "wait", "max_iterations", "review_patience", "base_ref"): val = cli_flags.get(flag) if val is not None: args.extend([f"--{flag.replace('_', '-')}", str(val)]) @@ -220,6 +220,12 @@ def _cleanup_ralphex_in_project(project_dir: Path) -> None: @click.option("--wait", type=str, default=None, help="Wait time") @click.option("--max-iterations", type=int, default=None, help="Max iterations") @click.option("--review-patience", type=int, default=None, help="Review patience") +@click.option( + "--base-ref", + type=str, + default=None, + help="Review diff base (branch name or commit hash); overrides build.review_executor.base_ref", +) @click.option("-e", "--env", "extra_env", multiple=True, help="Pass env var to container (KEY=VALUE)") @click.option("--proxy", type=str, default=None, help="HTTP/HTTPS proxy URL; overrides config.build.proxy") @click.option( @@ -264,6 +270,7 @@ def build( # noqa: PLR0913, C901, PLR0915, PLR0912, PLR0917 wait: str | None, max_iterations: int | None, review_patience: int | None, + base_ref: str | None, extra_env: tuple[str, ...], proxy: str | None, add_host: tuple[str, ...], @@ -348,6 +355,7 @@ def build( # noqa: PLR0913, C901, PLR0915, PLR0912, PLR0917 "wait": wait, "max_iterations": max_iterations, "review_patience": review_patience, + "base_ref": base_ref, "dry_run": dry_run, } diff --git a/goga/commands/history/.usages/history-command.md b/goga/commands/history/.usages/history-command.md new file mode 100644 index 00000000..2d7a3caa --- /dev/null +++ b/goga/commands/history/.usages/history-command.md @@ -0,0 +1,109 @@ +# history — goga history commands + +The `goga history` command group works with the `.goga/history/` tree from +the command line. For script authors (workflow scripts, skill prompts) and +operators. A topic value can always be given as a branch name +(`release/1.3.0`) or as a slug (`release-1-3-0`) — both address +`.goga/history/<year>/release-1-3-0/`. + +## Year addressing + +The year is addressed by the group option `-y/--year` — given once, before +the subcommand, and shared by every subcommand: + + goga history -y 2025 status + goga history --year 2025 ensure Feature/Foo_Bar + +- Without the option (and an empty value counts as absent) every subcommand + works with the current calendar year — except `list`, which prints the + full tree. +- The value is a plain string; four digits is the documented grammar of a + history year. `status`, `prune`, and `list` with a year missing from the + tree print nothing and exit 0; `path` composes the path regardless — + it never checks existence. +- A year option after the subcommand (`goga history status -y 2025`) is a + usage error; so are a positional YEAR and any other year form. + +## goga history list + +Prints the inventory tree — every year with its topics, or the scoped year +alone. No statuses, no artifacts. + + 2026/ + └── add-ref-for-review + └── history-commands + +- Read-only. An empty history prints nothing, exit 0. +- `goga history -y 2025 list` prints the 2025 section alone, same shape. + +## goga history status [-t TOPIC] [-s STATUS] + + goga history status + goga history -y 2025 status + goga history status --topic release + goga history status -s done -s mkdocs.published + +Prints one line per topic of the scoped year: the slug and every maximal +status in brackets, in scale order — for example "release-1-3-0 [done] +[mkdocs.published]". Status filters take qualified status names: built-in +names bare, tool statuses as <tool>.<name>; a record matches when any of +its maximal statuses is one of the requested names. An unknown name is a +clean error. `-t/--topic` keeps the topics whose slug contains the +normalized filter as a substring; it combines with `-s/--status` by AND, +and a filter that normalizes to an empty slug is a clean error. The year +is never printed; an empty result prints nothing and exits 0. + +## goga history path [TOPIC] [-f FILENAME] + +Prints one path of the scoped year — and nothing else — to stdout. Nothing +is created. + + goga history path # topic dir of the current branch + goga history path -f plan.md # …/plan.md of the current branch + goga history path release/1.3.0 -f plan.md # explicit topic (branch name ok) + goga history -y 2025 path # another year + +- Without `TOPIC` the current git branch names the topic. No branch (not a + repository, detached HEAD, git missing) → clean error, non-zero exit. +- `-f/--file` — an artifact filename with an extension; without the flag the + topic directory is printed. A filename without an extension is an error. +- Scripting pattern: `plan=$(goga history path -f plan.md)`. + +## goga history ensure [NAME] + +Creates the topic directory of the scoped year — idempotently. + + goga history ensure # topic of the current branch + goga history ensure Feature/Foo_Bar # → .goga/history/<year>/feature-foo-bar + goga history -y 2025 ensure fix-x # → .goga/history/2025/fix-x + +- An existing topic directory is a success, not a conflict. Occupancy + checks belong to the caller. +- Prints nothing on stdout; the exit code carries the result. + +## goga history prune [--dry-run] + +Deletes the orphan topics of the scoped year — the topics no branch of the +repository inventory hosts — and prints one slug per line. Nothing else +is printed; an empty result prints nothing and exits 0. + + goga history prune --dry-run # list the candidates, delete nothing + goga history prune # current year, delete the orphans + goga history -y 2025 prune # an explicit year + +- Protection: a local branch or a remote-tracking ref whose short name + normalizes to the topic slug protects the topic — in every year. +- Deletion is unconditional: no status protects a topic, the whole topic + directory goes with all artifacts. The tree is not in git — a deleted + topic directory is unrecoverable; run --dry-run first. +- Filesystem-only: branches, refs, and the index are never touched. + +## Errors + +Every failure is a clean message on stderr with a non-zero exit and no +fallback values: git unavailable / not a repository / detached HEAD, a +topic that normalizes to an empty slug, a filename without an extension, an +unknown status name, a tool package of the status scale that fails to +import (status), a topic directory that cannot be deleted (prune). Year +forms other than the group option — a positional YEAR, a year option after +the subcommand — are usage errors (non-zero exit, no traceback). diff --git a/goga/commands/history/CODEMANIFEST b/goga/commands/history/CODEMANIFEST new file mode 100644 index 00000000..529df2d5 --- /dev/null +++ b/goga/commands/history/CODEMANIFEST @@ -0,0 +1,300 @@ +Imports: + - Types: + - HistoryYear + - TopicRecord + - collect_history_tree + - collect_topic_statuses + - ensure_topic_dir + - normalize_topic_slug + - prune_topics + - resolve_current_branch_name + - resolve_topic_dir + - resolve_topic_file + - assemble_status_scale + Usages: + - topic-paths + - topic-statuses + - history-tree + - prune + From: goga/history + +Usages: + convention: .goga/usages/conventions.md + click: .goga/usages/cooks/click.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + Use the `click` practice to build the history command group: the group + decorator with its year option, the subcommand registration, the + arguments and options of each subcommand (the long/short option pairs, + the repeatable -s/--status option via multiple=True), the state + handover from the group to the subcommands, echo, and exit-code + propagation. + + This cell is the CLI surface of the history domain: a thin wrapper that + resolves inputs, delegates every computation to the domain routines, and + renders the results. No path building, no slug grammar, no status + resolution, and no tree walking live here. The year is addressed by the + group option alone — the subcommands carry no year surfaces of their + own. Domain errors surface as clean CLI errors (stderr, non-zero exit, + no traceback) — no fallback topic names and no silent skips. Use + relative imports. + +--- + +"history(year: str | None = None)": + location: history.py + annotations: | + The goga history command group — a click.Group container for the history + subcommands. Exported via __all__ and registered in the root application + group. The group carries the year scope every subcommand shares. + + `year`: the -y/--year group option — exactly one year, four digits the + recognized form; None and the empty string mean no year given; + the option is passed before the subcommand and applies to every + subcommand of the group + + Use the `click` practice for the group decorator, the group option, + and the subcommand registration. + + Subcommand surfaces: + - list — no arguments, no options + - status — --topic/-t (substring filter), -s/--status (repeatable + status filter) + - path — an optional TOPIC positional, -f/--file FILENAME + - ensure — an optional NAME positional + - prune — a --dry-run flag + + The scoped year reaches every subcommand; the CLI stays free of year + validation — the value is a plain string, the domain owns the year + semantics. The subcommand surfaces carry the CLI names; the Python + attribute of a callback may carry a suffix to avoid shadowing a + builtin (list -> list_topics) — the CLI name is the contract. + + Apply the `convention` CLI command docstring rule for the --help text + (rendered verbatim by Click; omit Args/Returns/Raises). + methods: + "list() -> exit_code: int": | + Subcommand goga history list: print the inventory tree — every year + with its topics when no year is given, the scoped year alone when it + is — no statuses and no artifacts. + + `exit_code`: 0 on success, 1 on error + + Apply the `history-tree` practice for the tree contract of the domain. + + Algorithm: + 1. Collect the tree via `collect_history_tree` with the scoped year + 2. Render it via `render_history_tree` + 3. An empty tree renders nothing — exit 0 + + Requirements: + - Read-only — nothing is created or written + - A scoped year missing from the tree renders nothing — exit 0 + + Constraints: + - Do not print statuses or artifact names — the list view carries + years and topics only + + "status(topic: str | None = None, statuses: tuple[str, ...]) -> exit_code: int": | + Subcommand goga history status: print the flat list of topics of the + scoped year, each line "topic [status] [status] ...". + + `topic`: --topic/-t value — a substring filter; the value is normalized + via `normalize_topic_slug` before matching + `statuses`: -s/--status values (repeatable, multiple=True) — qualified + status names to keep; combined with `topic` by AND + `exit_code`: 0 on success (an empty result included), 1 on error + + Apply the `topic-statuses` practice for the record and status + contracts of the domain. + Apply the `click` practice for the repeatable -s/--status option and + the color rules. + + Algorithm: + 1. Assemble the status scale via `assemble_status_scale` once — a + broken tool package of the assembly is a clean error (stderr, + non-zero exit) — and validate every name in `statuses` against it; + an unknown name is a clean error (stderr, non-zero exit) + 2. A `topic` value that normalizes to an empty slug is a clean error + (stderr, non-zero exit) — an empty filter would silently match + every topic and is rejected instead + 3. Collect the records via `collect_topic_statuses` with the scoped + year and the assembled scale — the single scale of this command run + 4. Filter: when `topic` is given, keep the records whose topic + contains the normalized filter as a substring; when `statuses` is + non-empty, keep the records carrying at least one of the resolved + names; both filters combine by AND + 5. Render the surviving records via `render_topic_statuses` + 6. An empty result renders nothing and exits 0 + + Requirements: + - Topics come out alphabetically — the domain sorts, the command does + not re-sort + - The year is never printed + - Color follows the `click` practice: ANSI only on a TTY, NO_COLOR + disables it always + - A record matches a status filter when any of its maximal statuses + is one of the requested names + - A scoped year with no matching directory yields an empty result — + the domain enumerates four-digit year directories only + + Constraints: + - Do not print the year, a header, or a summary line — one record per + line only + - Do not treat an empty result as an error + + "path(topic: str | None = None, filename: str | None = None) -> exit_code: int": | + Subcommand goga history path: print one path of the scoped year of the + history tree — and nothing else. + + `topic`: optional TOPIC positional — a branch name or a slug; None + means the current git branch + `filename`: -f/--file value — an artifact filename with an extension; + without the flag the topic directory is printed + `exit_code`: 0 on success, 1 on error + + Apply the `topic-paths` practice for the path contracts of the domain. + + Algorithm: + 1. Resolve `topic`: the positional when given, otherwise the current + branch via `resolve_current_branch_name`; an undetermined branch is + a clean error (stderr, non-zero exit) + 2. `filename` given -> resolve the file path via `resolve_topic_file` + with the scoped year; otherwise resolve the topic directory via + `resolve_topic_dir` with the scoped year + 3. Echo the resolved path to stdout — exactly one line, nothing else + + Requirements: + - Prints the path and only the path — no decoration, no trailing text + - Nothing is created on disk + + Constraints: + - Do not create the topic directory or the file — this subcommand is + read-only + - Do not print anything besides the path on stdout + + "ensure(name: str | None = None) -> exit_code: int": | + Subcommand goga history ensure: create the topic directory of the + scoped year. + + `name`: optional NAME positional — a branch name or a slug; None means + the current git branch + `exit_code`: 0 on success, 1 on error + + Apply the `topic-paths` practice for the creation contract of the + domain. + + Algorithm: + 1. Resolve `name`: the positional when given, otherwise the current + branch via `resolve_current_branch_name`; an undetermined branch is + a clean error (stderr, non-zero exit) + 2. Create the directory via `ensure_topic_dir` with `name` and the + scoped year — idempotently + 3. Exit 0 + + Requirements: + - An existing topic directory is a success, not a conflict + - Prints nothing on stdout — the exit code carries the result + + Constraints: + - Do not report occupancy — deciding whether a topic may be created + belongs to the caller + - Do not create artifact files inside the directory + + "prune(dry_run: bool = False) -> exit_code: int": | + Subcommand goga history prune: delete the orphan topics of the scoped + year — the topics no branch of the repository inventory hosts. + + `dry_run`: the --dry-run flag — list the deletion candidates without + deleting anything + `exit_code`: 0 on success (an empty result included), 1 on error + + Apply the `prune` practice for the orphan-cleanup contract of the + domain. + Apply the `click` practice for the flag and echo. + + Algorithm: + 1. Run the orphan cleanup via `prune_topics` with the scoped year and + `dry_run` + 2. A domain ValueError surfaces as a clean CLI error; a git + CalledProcessError surfaces as git failed, a missing git binary + as git is not available, and an OSError of the deletion as + cannot delete topic directory + 3. Echo one slug per line of the returned list + 4. An empty result prints nothing — exit 0 + + Requirements: + - The deletion is irreversible — the dry pass is the safe preview + - Only stdout carries the slug list — nothing else is printed + - A scoped year with no matching directory yields an empty result — + the domain enumerates four-digit year directories only + + Constraints: + - Do not ask for confirmation — the dry pass is the safety tool + - Do not compute orphan-hood or delete anything here — both belong to + the domain + +"render_history_tree(tree: list[HistoryYear])": + location: render.py + annotations: | + Render the history tree as the list-view output: one "YYYY/" header line + per year, each topic indented under its year with the tree marker. + + `tree`: the collected tree — years ascending, topics alphabetical + + Apply the `click` practice for echo. + + Algorithm: + 1. For each `HistoryYear` in `tree`, print the year followed by a slash + 2. Under it, print each topic on its own indented line prefixed with + the tree marker "└── " + 3. An empty `tree` prints nothing + + Requirements: + - The output shape is "YYYY/" then one indented topic line per topic — + no statuses, no artifacts + + Constraints: + - Read-only on `tree` — do not mutate it + - Do not compute anything — render what the collection already carries + +"render_topic_statuses(records: list[TopicRecord])": + location: render.py + annotations: | + Render the status view: one flat "topic [status] [status] ..." line per + record — every maximal status of the record, in scale order. + + `records`: the (already filtered) records to print + + Apply the `click` practice for echo, secho, and the color rules — ANSI + only on a TTY, NO_COLOR disables it always. + + Algorithm: + 1. For each `TopicRecord` in `records`, print the topic followed by one + bracketed status segment per maximal status, in scale order + 2. Colorize the status segments per the `click` practice; leave the + topic plain + 3. An empty `records` prints nothing + + Requirements: + - Every maximal status of the record is printed — none is hidden + - Empty input renders empty output — not an error + + Constraints: + - Read-only on `records` — do not mutate, do not re-sort, do not filter + - Do not print the year + +--- + +Author: Goga +CreatedAt: 28/08/26 +Description: | + The goga history command group with the list, status, path, ensure, and + prune subcommands over the history domain. diff --git a/goga/commands/history/__init__.py b/goga/commands/history/__init__.py new file mode 100644 index 00000000..a0f19cce --- /dev/null +++ b/goga/commands/history/__init__.py @@ -0,0 +1,6 @@ +"""History command cell — the CLI surface of the history domain.""" + +from .history import history +from .render import render_history_tree, render_topic_statuses + +__all__: list[str] = ["history", "render_history_tree", "render_topic_statuses"] diff --git a/goga/commands/history/history.py b/goga/commands/history/history.py new file mode 100644 index 00000000..ab0d96e9 --- /dev/null +++ b/goga/commands/history/history.py @@ -0,0 +1,235 @@ +"""The ``goga history`` command group — the CLI surface of the history domain. + +The click group declared in the cell CODEMANIFEST with ``location: +history.py``: the ``list``/``status``/``path``/``ensure``/``prune`` +subcommands over the ``.goga/history/`` tree. The group carries the year +scope every subcommand shares — the ``-y/--year`` option addressed before +the subcommand; the subcommands themselves carry no year surfaces of their +own. The group is a thin wrapper — it resolves the inputs, delegates every +computation to the domain routines of ``goga.history``, and renders the +results through the ``render`` module. No path building, no slug grammar, +no year validation, and no status resolution live here. Domain errors +surface as clean CLI errors: a ``ValueError`` from the domain and an +undetermined git branch become ``click.ClickException`` (stderr, exit 1, +no traceback) — no fallback topic names, no silent skips. +""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass + +import click + +from ...history import ( + assemble_status_scale, + collect_history_tree, + collect_topic_statuses, + ensure_topic_dir, + normalize_topic_slug, + prune_topics, + resolve_current_branch_name, + resolve_topic_dir, + resolve_topic_file, +) +from .render import render_history_tree, render_topic_statuses + + +@dataclass(kw_only=True) +class _HistoryScope: + """The year scope shared by every subcommand of the group.""" + + year: str | None = None + + +def _resolve_topic_input(topic: str | None) -> str: + """Resolve a topic input: the positional when given, the branch otherwise. + + Args: + topic: The positional value — ``None`` when the user passed none. + + Returns: + The topic input to hand to the domain (verbatim, unnormalized). + + Raises: + click.ClickException: when the positional is absent and the current + git branch cannot be determined (the three documented ``None`` + modes of the domain branch reader). + """ + if topic is not None: + return topic + branch = resolve_current_branch_name() + if branch is None: + raise click.ClickException("cannot determine the current git branch — pass a topic explicitly") + + return branch + + +@click.group() +@click.option( + "--year", + "-y", + default=None, + help="Four-digit year shared by every subcommand (without it: " + "list prints every year, the others take the current year)", +) +@click.pass_context +def history(ctx: click.Context, year: str | None = None) -> None: + """Work with the .goga/history/ tree.""" + ctx.ensure_object(_HistoryScope) + ctx.obj.year = year + + +@history.command("list") +@click.pass_obj +def list_topics(scope: _HistoryScope) -> None: + """Print the inventory tree — every year with its topics, or the year given via -y/--year alone. + + The inventory view: one YYYY/ line per year, each topic indented under + its year. An empty tree prints nothing. Read-only — nothing is created + or written; statuses and artifact names never appear. + """ + render_history_tree(collect_history_tree(scope.year)) + click.get_current_context().exit(0) + + +@history.command("status") +@click.option("-t", "--topic", default=None, help="Substring filter on the normalized topic slug.") +@click.option("-s", "--status", "statuses", multiple=True, help="Status filter, repeatable (e.g. -s planned).") +@click.pass_obj +def status(scope: _HistoryScope, topic: str | None = None, statuses: tuple[str, ...] = ()) -> None: + """Print the topics of one year, one 'topic [status] [status] …' line each. + + A topic carries its maximal statuses in scale order — one bracketed + segment per status, tool statuses included. The year comes from the + group's -y/--year (default: the current year) and is never printed. + -t/--topic keeps the topics whose slug contains the normalized filter + as a substring; -s/--status keeps the topics carrying at least one of + the requested statuses; both filters combine by AND. An empty result + prints nothing and exits 0 — it is not an error. The topics come out + alphabetically; the domain sorts, this command does not re-sort. + """ + try: + scale = assemble_status_scale() + except (ValueError, ImportError) as exc: + raise click.ClickException(str(exc)) from exc + + for name in statuses: + try: + scale.resolve_status(name) + except ValueError as exc: + raise click.ClickException(f"unknown status name: {name!r}") from exc + + filter_slug: str | None = None + + if topic is not None: + filter_slug = normalize_topic_slug(topic) + if filter_slug == "": + raise click.ClickException(f"topic filter {topic!r} normalizes to an empty topic slug") + + records = collect_topic_statuses(scope.year, scale) + + if topic is not None: + records = [record for record in records if filter_slug in record.topic] + if statuses: + requested = set(statuses) + records = [record for record in records if set(record.statuses) & requested] + render_topic_statuses(records) + click.get_current_context().exit(0) + + +@history.command("path") +@click.argument("topic", required=False) +@click.option( + "-f", + "--file", + "filename", + default=None, + help="Print the artifact file path instead of the topic directory.", +) +@click.pass_obj +def path(scope: _HistoryScope, topic: str | None = None, filename: str | None = None) -> None: + """Print one path of the history tree — and nothing else. + + TOPIC defaults to the current git branch (taken raw, as a branch name or + a slug). With -f/--file the artifact file path is printed, otherwise the + topic directory; the year comes from the group's -y/--year (default: + the current one). The path and only the path — exactly one stdout line, + for scripting: plan=$(goga history path -f plan.md). Nothing is created + on disk. + """ + resolved_topic = _resolve_topic_input(topic) + + try: + if filename is not None: + resolved_path = resolve_topic_file(resolved_topic, filename, scope.year) + else: + resolved_path = resolve_topic_dir(resolved_topic, scope.year) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(resolved_path) + click.get_current_context().exit(0) + + +@history.command("ensure") +@click.argument("name", required=False) +@click.pass_obj +def ensure(scope: _HistoryScope, name: str | None = None) -> None: + """Create the topic directory of the scoped year, idempotently. + + NAME defaults to the current git branch (taken raw, as a branch name or + a slug); parent directories are created as needed and an existing topic + directory is a success, not a conflict. The year comes from the group's + -y/--year (default: the current one). Prints nothing on stdout — the + exit code carries the result. Only directories: no artifact file is + created, and occupancy is not reported (deciding whether a topic may be + created belongs to the caller). + """ + resolved_name = _resolve_topic_input(name) + + try: + ensure_topic_dir(resolved_name, scope.year) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + click.get_current_context().exit(0) + + +@history.command("prune") +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="List the deletion candidates without deleting anything.", +) +@click.pass_obj +def prune(scope: _HistoryScope, dry_run: bool = False) -> None: + """Delete the orphan topics of one year — the topics no branch hosts. + + A local branch or a remote-tracking ref whose short name normalizes to + the topic slug protects it, in every year; every other topic of the + year is an orphan and goes. The year comes from the group's -y/--year + (default: the current year) — only that year is touched. Every removed + topic is printed as one slug per line, and nothing else; an empty + result prints nothing and exits 0. The deletion is filesystem-only (no + branch, ref, or index of git is touched) and unconditional — no status + protects a topic. It is also irreversible: the history tree is not in + git, so a deleted topic directory cannot be recovered. Run the command + with --dry-run first to preview the candidates. + """ + try: + removed = prune_topics(scope.year, dry_run) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except OSError as exc: + # FileNotFoundError is matched above — the git-less binary never + # lands here; this wraps the rmtree failures of the deletion. + raise click.ClickException(f"cannot delete topic directory: {exc}") from exc + + for slug in removed: + click.echo(slug) + click.get_current_context().exit(0) diff --git a/goga/commands/history/render.py b/goga/commands/history/render.py new file mode 100644 index 00000000..3a2b5a83 --- /dev/null +++ b/goga/commands/history/render.py @@ -0,0 +1,51 @@ +"""Console rendering for the history command group. + +The entities declared in the cell CODEMANIFEST with ``location: render.py``: +the list-view tree renderer and the flat status-view renderer. Both are pure +output — what the input carries is printed as given, never sorted, filtered, +or recomputed; the caller owns the collection and the filtering. +""" + +from __future__ import annotations + +import os + +import click + +from ...history import HistoryYear, TopicRecord + + +def render_history_tree(tree: list[HistoryYear]) -> None: + """Render the history tree as the list-view output. + + One ``YYYY/`` line per year, each topic of the year on its own indented + line under the tree marker. An empty tree renders nothing. + + Args: + tree: The collected tree — years ascending, topics alphabetical. + """ + for year_record in tree: + click.echo(f"{year_record.year}/") + for topic in year_record.topics: + click.echo(f" └── {topic}") + + +def render_topic_statuses(records: list[TopicRecord]) -> None: + """Render the status view — one flat ``topic [status] …`` line per record. + + The topic prints plain with a trailing space and no newline; the bracketed + status names follow, space-separated, as the colored segment + (``cyan``). A non-empty ``NO_COLOR`` keeps the segments plain — click does + not honor the variable, so it is checked explicitly. An empty input + renders nothing. + + Args: + records: The records to print — already filtered by the caller. + """ + for record in records: + click.echo(f"{record.topic} ", nl=False) + status_segments = " ".join(f"[{status_name}]" for status_name in record.statuses) + if os.environ.get("NO_COLOR"): + click.echo(status_segments) + else: + click.secho(status_segments, fg="cyan") diff --git a/goga/commands/hooks/.usages/hooks-command.md b/goga/commands/hooks/.usages/hooks-command.md new file mode 100644 index 00000000..cabaef03 --- /dev/null +++ b/goga/commands/hooks/.usages/hooks-command.md @@ -0,0 +1,30 @@ +# commands — the hooks command + +How to inspect the hooks registered by the installed tool packages. For +goga users; the command reads the registry and prints it. + +## The tree + + goga hooks + +One tool line per tool with registrations; under a tool, one domain line +per domain; under a domain, the actions the tool subscribed to. A refused +registration prints its reason. A tool without registrations prints its +line alone. No root line. + +## The slice + + goga hooks --tool my-tool + goga hooks -t my-tool -t other-tool + +The repeatable option narrows the tree to the named tools — the name +without the goga_tool_ prefix, as the tool line of the tree shows it. +A requested tool without registrations shows its empty entry. + +## Behavior + +- The command assembles the registry once at start; a broken package + import fails the command with the package name. +- The view states the fact of registration, not whether a hook ran in a + particular command. +- An empty result prints nothing and exits 0. diff --git a/goga/commands/hooks/CODEMANIFEST b/goga/commands/hooks/CODEMANIFEST new file mode 100644 index 00000000..60fa37cc --- /dev/null +++ b/goga/commands/hooks/CODEMANIFEST @@ -0,0 +1,100 @@ +Imports: + - Types: + - HookRegistry + - ToolHooks + From: goga/hooks + +Usages: + convention: .goga/usages/conventions.md + click: .goga/usages/cooks/click.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + Use the `click` practice to build the command: the top-level command + registration in the root application group, the repeatable --tool/-t + option via multiple=True on a single Option, echo, and exit-code + propagation. + + This cell is the CLI surface of the hooks inspection: a thin wrapper + that creates the run registry, assembles it once, slices it by tool, + and renders the tree. No registry computation, no delivery, no action + emission live here. A broken package import surfaces as a clean CLI + error — stderr, non-zero exit, no traceback. Use relative imports. + +--- + +"hooks(tools: tuple[str, ...]) -> exit_code: int": + location: hooks.py + annotations: | + The goga hooks command — the inspection of the hooks registered by + the installed tool packages. Exported via __all__ and registered in + the root application group. + + `tools`: --tool/-t values (repeatable) — the tool names of the slice, + without the package prefix; empty means every tool + `exit_code`: 0 on success (an empty view included), 1 on error + + Apply the `convention` CLI command docstring rule for the --help text + (rendered verbatim by Click; omit Args/Returns/Raises). + Use the `click` practice for the repeatable option and the color + rules. + + Algorithm: + 1. Create the run registry via `HookRegistry` and assemble it via its + build_once method + 2. Read the per-tool view via the by_tool method + 3. A non-empty `tools` keeps the entries of the named tools — a + requested name without registrations keeps its entry with an empty + list, not an error + 4. Render the view via `render_hooks_tree` + 5. An empty view renders nothing — exit 0 + + Requirements: + - A broken package import surfaces as a clean CLI error naming the + package + - The command shows the fact of registration — never the application + of a hook in some command + + Constraints: + - Do not emit any action here — the inspection reads the registry only + +"render_hooks_tree(view: list[ToolHooks])": + location: render.py + annotations: | + Render the registry view as the tool tree — tool, then domain, then + action, with the refused registrations and their reasons. + + `view`: the per-tool entries — the already sliced set + + Use the `click` practice for echo and the color rules. + + Algorithm: + 1. Print one tool line per entry — the tool identity + 2. Under a tool, print one domain line per distinct domain of its + subscriptions, ordered alphabetically + 3. Under a domain, print one action line per subscription of that + domain + 4. Print every refused registration of the tool with its reason + 5. An empty `view` prints nothing + + Requirements: + - The tree carries no root line — the tools are the top level + - A tool without subscriptions and without refusals prints its line + alone + + Constraints: + - Read-only on `view` — do not mutate, do not re-sort the tool entries + +--- + +Author: Goga +CreatedAt: 31/08/26 +Description: | + The goga hooks command — the inspection of the hooks registered by the + installed tool packages. diff --git a/goga/commands/hooks/__init__.py b/goga/commands/hooks/__init__.py new file mode 100644 index 00000000..640aaaa9 --- /dev/null +++ b/goga/commands/hooks/__init__.py @@ -0,0 +1,6 @@ +"""Hooks command cell — the CLI surface of the hooks inspection.""" + +from .hooks import hooks +from .render import render_hooks_tree + +__all__: list[str] = ["hooks", "render_hooks_tree"] diff --git a/goga/commands/hooks/hooks.py b/goga/commands/hooks/hooks.py new file mode 100644 index 00000000..e3c54db7 --- /dev/null +++ b/goga/commands/hooks/hooks.py @@ -0,0 +1,73 @@ +"""The ``goga hooks`` command — the inspection of the registered hooks. + +The entity declared in the cell CODEMANIFEST with ``location: hooks.py``: the +``hooks`` click command. It is a thin inspection wrapper — it creates the run +registry, assembles it once, applies the ``--tool`` slice, and hands the view +to the renderer. No registry computation, no delivery, and no action emission +live here: the command reads the registry and states the fact of registration, +never the application of a hook in some command. A broken package import +surfaces as a clean CLI error — stderr, exit 1, no traceback. +""" + +from __future__ import annotations + +import click + +from ...hooks import HookRegistry, ToolHooks +from .render import render_hooks_tree + + +def _slice_view(view: list[ToolHooks], tools: tuple[str, ...]) -> list[ToolHooks]: + """Narrow the per-tool view to the requested tools. + + Args: + view: The per-tool entries of the registry, alphabetical by tool. + tools: The requested tool names — the identities without the package + prefix; empty means every tool. + + Returns: + The entries of the requested tools in view order, followed by one + empty entry per requested name without registrations, in request + order. A repeated request yields one entry; an unknown name is not an + error. + """ + requested = list(dict.fromkeys(tools)) + known = {entry.tool for entry in view} + selected = [entry for entry in view if entry.tool in known & set(requested)] + missing = [ToolHooks(tool=name, subscriptions=[], rejections=[]) for name in requested if name not in known] + return selected + missing + + +@click.command() +@click.option("--tool", "-t", "tools", multiple=True, help="Narrow the tree to the named tools (repeatable).") +@click.pass_context +def hooks(ctx: click.Context, tools: tuple[str, ...] = ()) -> None: + """Inspect the hooks registered by the installed tool packages. + + The registry assembles once and prints as a tree: tool, then domain, then + action, with every refused registration and its reason. A tool with no + subscriptions and no refusals prints its line alone; an empty registry + prints nothing and exits 0. -t/--tool narrows the tree to the named tools + — the tool identity, without the package prefix, as the tool line shows + it; the option is repeatable, and a requested name without registrations + keeps an empty entry, not an error. A broken package import fails the + command with a clean error naming the package. + \f + The ``exit_code`` contract follows the ``goga/commands/history`` + precedent: the callback is annotated ``-> None``, returns nothing, and + exits through ``ctx.exit(0)`` — errors propagate as + ``click.ClickException`` (stderr, exit 1, no traceback). The command + emits no action; only the hook checkpoints of the domains do. + """ + try: + registry = HookRegistry() + registry.build_once() + except ImportError as exc: + raise click.ClickException(str(exc)) from exc + + view = registry.by_tool() + + if tools: + view = _slice_view(view, tools) + render_hooks_tree(view) + ctx.exit(0) diff --git a/goga/commands/hooks/render.py b/goga/commands/hooks/render.py new file mode 100644 index 00000000..f7a46ce6 --- /dev/null +++ b/goga/commands/hooks/render.py @@ -0,0 +1,40 @@ +"""Console rendering for the ``goga hooks`` command. + +The entity declared in the cell CODEMANIFEST with ``location: render.py``: +the tree renderer of the per-tool registry view. It is pure output — the view +prints as given, never mutated and never re-sorted; the caller owns the +collection and the slice. Only the domain lines of one tool are ordered, +alphabetically, as the documented tree fixes it. +""" + +from __future__ import annotations + +import click + +from ...hooks import ToolHooks + + +def render_hooks_tree(view: list[ToolHooks]) -> None: + """Render the registry view as the tool tree. + + One tool line per entry — the tools are the top level, there is no root + line. Under a tool, one domain line per distinct domain of its + subscriptions, ordered alphabetically; under a domain, one action line per + subscription of that domain — the action name, two spaces, the bare hook + name. Every refused registration of the tool prints after the domains with + its reason, the attempted name in double quotes. A tool without + subscriptions and without refusals prints its line alone; an empty view + prints nothing. + + Args: + view: The per-tool entries — already sliced by the caller. + """ + for entry in view: + click.echo(entry.tool) + for domain in sorted({subscription.domain for subscription in entry.subscriptions}): + click.echo(f" {domain}") + for subscription in entry.subscriptions: + if subscription.domain == domain: + click.echo(f" {subscription.action} {subscription.name}") + for rejection in entry.rejections: + click.echo(f' rejected {rejection.domain}/{rejection.action} "{rejection.name}": {rejection.reason}') diff --git a/goga/commands/install/.usages/install.md b/goga/commands/install/.usages/install.md index 42d35823..a9f9848c 100644 --- a/goga/commands/install/.usages/install.md +++ b/goga/commands/install/.usages/install.md @@ -3,141 +3,182 @@ ## Overview The `goga install` command adds goga-tool packages into the current runtime -interpreter — the exact Python that runs goga — and then activates every -already-connected agent so the freshly installed skills and pipelines appear in -`~/.goga/` and in each agent's symlink tree. +interpreter — the exact Python that runs goga — then runs each installed +tool's optional post-install hook, and finally activates every +already-connected agent so the freshly installed skills and pipelines +appear in `~/.goga/` and in each agent's symlink tree. The command operates in four modes: -- **Single mode** (`goga install <name>`): install one tool. `--version` resolves - via the four-form grammar; the project config is ignored. -- **Bulk mode** (`goga install`): install every tool declared in the `tools` - section of `.goga/config.yml`, in a single pip invocation, in YAML order. +- **Single mode** (`goga install <name>`): install one tool. `--version` + resolves via the four-form grammar; the project config is ignored. +- **Bulk mode** (`goga install`): install every tool declared in the + `tools` section of `.goga/config.yml`, in a single pip invocation, in + YAML order. - **Empty mode** (`goga install` with no `tools` section): no-op, prints `Nothing to install`, exits 0. -- **Local mode** (`goga install --local <path>` / `-l <path>`): pip-install a - local directory; mutually exclusive with `name` and `--version`. +- **Local mode** (`goga install --local <path>` / `-l <path>`): pip-install + a local directory; mutually exclusive with `name` and `--version`. The + value is `<path>` or `<path>:<tool-name>`. -After a successful pip in single, local, and bulk mode, the command runs a post-install -activation: it re-syncs every agent recorded in `~/.goga/connect.yml` (using each -agent's persisted `force_overwrite`) so the new tool's skills and pipelines are -linked into place. Pass `--no-connect` to skip activation — the command performs -the install only (useful in CI/Docker where a non-zero activation must not fail -the install). Empty mode performs neither pip nor activation. +After a successful pip in single, local, and bulk mode the command runs +the post-install hooks (see below), then the activation re-sync of every +agent recorded in `~/.goga/connect.yml`. Pass `--no-connect` to skip the +activation only — the hooks still run. ## CLI Usage ### Single mode -```bash -# Plain install + activation — current user, no sudo, latest version -goga install foo + # Plain install + activation — current user, no sudo, latest version + goga install foo -# Install a specific concrete version, then activate -goga install foo --version 1.0.1 + # Install a specific concrete version, then activate + goga install foo --version 1.0.1 -# Short alias form — identical to the line above -goga install foo -v 1.0.1 + # Short alias form — identical to the line above + goga install foo -v 1.0.1 -# Install within a minor x-range (>=1.0.0, <1.1.0) -goga install foo --version 1.0.x + # Install within a minor x-range (>=1.0.0, <1.1.0) + goga install foo --version 1.0.x -# Install with sudo (system-Python installs requiring root); activation runs -# without sudo against the preserved $HOME -goga install foo --sudo + # Install with sudo (system-Python installs requiring root); hooks and + # activation run without sudo, the hook receives the current OS user + # (SUDO_USER only when goga itself runs under sudo) + goga install foo --sudo -# Install only — skip activation (escape-hatch for CI/Docker) -goga install foo --no-connect -``` + # Install only — skip activation; the post-install hook still runs + goga install foo --no-connect ### Bulk mode Declare tools in `.goga/config.yml`: -```yaml -tools: - viewer: latest # → no specifier (pip selects newest) - afm: 1.0.x # → ~=1.0.0 (minor x-range) - ralphex: 1.x # → ~=1.0 (major x-range) - go: 1.0.1 # → ==1.0.1 (concrete pin) -``` + tools: + viewer: latest # → no specifier (pip selects newest) + afm: 1.0.x # → ~=1.0.0 (minor x-range) + ralphex: 1.x # → ~=1.0 (major x-range) + go: 1.0.1 # → ==1.0.1 (concrete pin) -Then install and activate in a single command: +Then install in a single command: -```bash -# Install every declared tool, then one activation pass over connect.yml -goga install + # Install every declared tool, run each tool's hook in YAML order, + # then one activation pass + goga install -# Same, but pip under sudo with HOME preserved -goga install --sudo + # Same, but pip under sudo with HOME preserved + goga install --sudo -# Bulk install only, no activation -goga install --no-connect -``` + # Bulk install with hooks, no activation + goga install --no-connect -Bulk mode issues **exactly one** `pip install` call whose argv contains every -resolved `goga-tool-<name><spec>` in YAML order, followed by one activation pass. +Bulk mode issues **exactly one** `pip install` call whose argv contains +every resolved `goga-tool-<name><spec>` in YAML order; hooks run per tool +in the same order, then one activation pass. ### Local mode Install a goga-tool from a local source directory instead of PyPI: -```bash -# Install the package located at ./my-tool, then activate -goga install --local ./my-tool + # Install the package located at ./my-tool, then activation. + # No hook runs — a warning names the way to enable it + goga install --local ./my-tool -# Short alias form — identical -goga install -l ./my-tool + # Same path with the :<tool-name> suffix — the post-install hook of + # goga_tool_mytool runs after pip + goga install --local ./my-tool:mytool -# Local install only, no activation -goga install --local ./my-tool --no-connect + # Short alias form — identical + goga install -l ./my-tool:mytool -# Local install under sudo (system-Python); activation runs without sudo -goga install --local ./my-tool --sudo -``` + # Local install only, no activation; the hook still runs (suffix present) + goga install --local ./my-tool:mytool --no-connect + + # Local install under sudo (system-Python); hooks and activation run + # without sudo, the hook receives the current OS user (SUDO_USER only + # when goga itself runs under sudo) + goga install --local ./my-tool:mytool --sudo Local mode issues exactly one `pip install <path> -U` against the current -interpreter, then activates every agent in ~/.goga/connect.yml by the same rules -as single/bulk mode (suppressed by `--no-connect`). Pip's return code is -translated unchanged — a missing or non-installable path surfaces as pip's own -non-zero exit code. +interpreter, then the hook of the suffixed tool (when given), then +activation by the same rules as single/bulk mode (suppressed by +`--no-connect`). Pip's return code is translated unchanged — a missing or +non-installable path surfaces as pip's own non-zero exit code. Constraints: -- `name` and `--local` are mutually exclusive (`goga install foo --local ./x` - exits 1). +- `name` and `--local` are mutually exclusive (`goga install foo --local + ./x` exits 1). - `--version` is rejected in local mode (`goga install --local ./x -v 1.0.1` exits 1) — versions apply to PyPI packages only. +- A malformed suffix (empty tool name, a path separator, an extra colon) + exits 1 before any pip. - Editable installs (`-e`) are not performed. ### Empty mode When the `tools` section is absent or empty in `.goga/config.yml`: -```bash -goga install -# stdout: Nothing to install -# exit code: 0 -``` + goga install + # stdout: Nothing to install + # exit code: 0 -Neither pip nor activation is invoked. +Neither pip, nor hooks, nor activation is invoked. ## Options | Option | Type | Default | Purpose | |---|---|---|---| | `name` (positional, optional) | string | None | Tool name without the goga-tool- / goga_tool_ prefix. When absent, bulk/empty mode runs from `config.tools`. | -| `--sudo` | flag | False | Run pip under `sudo --preserve-env=HOME` (Unix-only). Applies to pip only; activation never uses sudo. | -| `--version <form>`, `-v <form>` | string | None | Version form in the four-form grammar. Used by single mode only; ignored in bulk mode. Both forms are aliases on the same option — `-v 1.0.x` is identical to `--version 1.0.x`. | -| `--local <path>`, `-l <path>` | string | None | Path to a pip-installable local directory. When set (and `name` is absent), installs from the local path instead of PyPI. Mutually exclusive with `name`; `--version` is rejected in this mode. `-l ./my-tool` is identical to `--local ./my-tool`. | -| `--no-connect` | flag | False | Skip post-install activation. When set, the command performs the install only and the exit code is pip's. | +| `--sudo` | flag | False | Run pip under `sudo --preserve-env=HOME` (Unix-only). Applies to pip only; hooks and activation never use sudo. | +| `--version <form>`, `-v <form>` | string | None | Version form in the four-form grammar. Single mode only. Both forms are aliases on the same option — `-v 1.0.x` is identical to `--version 1.0.x`. | +| `--local <path>[:<tool-name>]`, `-l` | string | None | Pip-installable local directory, optionally followed by `:<tool-name>` — the name of the tool whose `install()` hook should run. Without the suffix no hook runs (a warning names the suffix as the way to enable it). `-l ./x:foo` is identical to `--local ./x:foo`. | +| `--no-connect` | flag | False | Skip the post-install activation re-sync only. Hooks still run after a successful pip. | + +## Post-install Hooks + +After a successful pip install (single, local with a `:<tool-name>` +suffix, and bulk), the command imports each installed tool's facade module +`goga_tool_<name>` and calls its `install` callable when one exists. The +tool identifier is normalized into the module name first — every hyphen and +dot becomes an underscore and the result is lowercased (`mytool` → +`goga_tool_mytool`, `my-tool` → `goga_tool_my_tool`), matching the +underscored top-level package pip lays out on disk: + +- No facade module or no callable `install` → quiet skip (the hook is + optional; tools without a hook install unchanged). +- The hook's signature declares a keyword-capable parameter `user` → it is + called as `install(user=<initiating user>)`; otherwise it is called with + no arguments. +- The initiating user is `SUDO_USER` when goga itself runs under sudo + (`sudo goga install ...`), otherwise the current OS user — the actual + initiator, not root. The `--sudo` flag runs only pip under sudo and does + not set `SUDO_USER`. What the + tool does with the string (chown, per-user config, git identity) is the + tool's business; goga does not re-execute the hook as that user. +- Hook failure (an exception from the hook body): non-zero exit with the + tool name and the hook message; the pip package stays installed (no + rollback); the activation re-sync does not run. In bulk mode the + sequence stops at the first failing hook — the remaining tools' hooks + are not called. +- Hooks run before the activation re-sync; `--no-connect` suppresses only + the re-sync. +- Hooks are not run by `uninstall` or `upgrade`. + +A tool with a hook looks like this: + + # inside the goga_tool_mytool facade package + def install(user: str | None = None) -> None: + ... # tool-owned setup; `user` receives the initiating user + # only when the parameter is declared keyword-capable ## Post-install Activation -When pip succeeds in single, local, or bulk mode and `--no-connect` is not set, the -command activates every agent listed in `~/.goga/connect.yml`, each with its own -recorded `force_overwrite`. Activation is a local operation on `$HOME` and never -runs under `--sudo`. A missing or empty registry is a no-op that returns 0: the -tool is installed on the interpreter but not yet linked to any agent — connect an +When pip and hooks succeed in single, local, or bulk mode and +`--no-connect` is not set, the command activates every agent listed in +`~/.goga/connect.yml`, each with its own recorded `force_overwrite`. +Activation is a local operation on `$HOME` and never runs under `--sudo`. +A missing or empty registry is a no-op that returns 0: the tool is +installed on the interpreter but not yet linked to any agent — connect an agent later with `goga connect <agent>` and the tool will be picked up. ## Version Form Grammar @@ -154,53 +195,47 @@ specifiers as follows: | Latest marker | `latest` | *no specifier* | pip selects newest under `-U` | | (absent `--version`) | — | *no specifier* | same as `latest` (single mode only) | -Rejected forms: operator-prefixed (`==1.0`, `>=1.0`), malformed (`1.x.0`), and -YAML-null `tools` values (e.g. `viewer:`) — each exits non-zero with a clear error. +Rejected forms: operator-prefixed (`==1.0`, `>=1.0`), malformed (`1.x.0`), +and YAML-null `tools` values (e.g. `viewer:`) — each exits non-zero with a +clear error. ## Python API -```python -from goga.commands.install.install import install + from goga.commands.install import install -# Click commands are normally invoked via the CLI. For testing or programmatic -# invocation, use click.testing.CliRunner — see .goga/usages/cooks/click.md. -``` + # Click commands are normally invoked via the CLI. For testing or + # programmatic invocation, use click.testing.CliRunner to drive the + # command in-process. ## Return Values | Exit code | Condition | |---|---| -| 0 | pip succeeded and activation succeeded (or registry missing/empty), or empty mode no-op | -| non-zero (pip) | pip failed — its returncode propagated verbatim; activation is not run | -| non-zero (activation) | pip succeeded but activation failed for one or more agents — the first non-zero per-agent failure is returned | -| 1 (`ClickException`) | a version form was rejected, or `load_project_config` failed in bulk/empty mode | -| 1 (ClickException) | `name` + `--local` combined, or `--version` supplied in local mode | - -With `--no-connect`, the exit code is always pip's (install-only semantics). +| 0 | pip succeeded, all hooks succeeded (or none applied), and activation succeeded (or registry missing/empty, or `--no-connect`); or empty mode no-op | +| non-zero (pip) | pip failed — its returncode propagated verbatim; no hooks, no activation | +| non-zero (hook) | pip succeeded but a hook raised — tool name + message; no rollback, no activation; bulk stops at the first failing hook | +| non-zero (activation) | pip and hooks succeeded but activation failed for one or more agents — the first non-zero per-agent failure is returned | +| 1 (`ClickException`) | a version form was rejected; `load_project_config` failed in bulk/empty mode; `name` + `--local` combined; `--version` in local mode; malformed `--local` suffix (empty tool name, path separator, extra colon) | ## Side Effects -- Runs pip as a subprocess of the current interpreter (network/disk activity; - may require root under system-Python installs). -- The installed package(s) become importable in the running interpreter's - environment. +- Runs pip as a subprocess of the current interpreter (network/disk + activity; may require root under system-Python installs). +- Runs each installed tool's `install()` hook — arbitrary code provided by + the installed package, with the trust level of any pip package. - On success (and without `--no-connect`), activates every agent in - `~/.goga/connect.yml`: recreates central assets, downloads `dsl.md`, creates - agent symlinks, and refreshes pipelines — via the shared activation routine. -- During activation, emits a `Re-syncing <N> registered agent(s): <list>` banner - to stderr followed by a `Connecting agent: <name>` line per agent. A missing - or empty registry is a silent no-op (no banner). -- Bulk mode performs exactly one `subprocess.run` regardless of how many tools - are declared. + `~/.goga/connect.yml` via the shared activation routine. +- A local install without the `:<tool-name>` suffix logs a warning that no + hook will run and how to enable it. ## Preconditions - The current interpreter (`sys.executable`) must be the one where goga is installed. -- The caller must have write access to the site-packages directory (or pass - `--sudo`) and to `~/.goga/` for activation. -- For bulk mode, `.goga/config.yml` must exist and contain a `tools` section - (otherwise empty mode runs — a no-op, not an error). +- The caller must have write access to the site-packages directory (or + pass `--sudo`) and to `~/.goga/` for activation. +- For bulk mode, `.goga/config.yml` must exist and contain a `tools` + section (otherwise empty mode runs — a no-op, not an error). - On Windows, `--sudo` is unavailable (sudo is Unix-only). ## Anti-patterns @@ -210,9 +245,17 @@ With `--no-connect`, the exit code is always pip's (install-only semantics). - Do not declare `tools:` values with YAML-null (`viewer:`) — write `viewer: latest`. - Do not bypass the command and call `pip` with `sudo` directly without - `--preserve-env=HOME`: post-install activation depends on reading the caller's - `$HOME`. -- Do not expect `--version` to apply in bulk mode — it is ignored when `name` - is absent. -- In CI/Docker where a transient activation failure must not fail the install, - pass `--no-connect` to keep install-only exit semantics. + `--preserve-env=HOME`: the post-install activation depends on the caller's + `$HOME` (the hook's initiating-user resolution reads `SUDO_USER` / the OS + user, never `$HOME`). +- Do not expect `--version` to apply in bulk mode — it is ignored when + `name` is absent. +- Do not expect a hook for a local install without the suffix — goga does + not guess the tool name from the path; pass + `--local ./my-tool:mytool` explicitly. +- Do not expect `--no-connect` to suppress hooks — it suppresses the + activation re-sync only. +- Do not rely on a pip rollback after a hook failure — the package stays + installed; fix the tool or uninstall it. +- In CI/Docker where a transient activation failure must not fail the + install, pass `--no-connect` (hooks still gate the exit code). diff --git a/goga/commands/install/CODEMANIFEST b/goga/commands/install/CODEMANIFEST index edc467ac..006ea0e6 100644 --- a/goga/commands/install/CODEMANIFEST +++ b/goga/commands/install/CODEMANIFEST @@ -54,41 +54,60 @@ Annotations: | pip invocation through the current interpreter, HOME resolution by user name, and goga-home path construction. + After a successful pip install in single, local, and bulk mode, every + installed tool gets its post-install hook: the goga_tool_<tool> facade is + imported and its install callable, when present, runs with the initiating + user injected by signature. The hook step sits between pip and the agent + re-sync; the --no-connect flag suppresses only the re-sync — the hook runs + regardless. A hook failure fails the command with a non-zero exit, leaves + the pip install in place, and skips the re-sync; in bulk mode the sequence + stops at the first failing hook. + --- "install(ctx: click.Context, name: str | None, sudo: bool, version: str | None, local: str | None, no_connect: bool = False) -> exit_code: int": location: install.py annotations: | - Install one or more goga-tool packages into the current runtime interpreter - via pip and, on success, activate every already-connected agent. Branches - across four paths: single (one named tool from PyPI with an optional - four-form version), bulk (every tool declared in config.tools in a single - pip invocation), empty (Nothing to install), and LOCAL (one pip-installable - local directory). After a successful pip in single, local, and bulk mode, - runs the activation re-sync unless `no_connect` is set. Propagates pip's - returncode as the exit code whenever pip is invoked or re-sync is skipped; - otherwise propagates the re-sync outcome (first non-zero per-agent failure). - - `exit_code`: pip's outcome when pip is invoked or re-sync is skipped; the - first non-zero per-agent re-sync failure otherwise; 0 in the empty path. + Install one or more goga-tool packages into the current runtime + interpreter via pip, run every installed tool's post-install hook, and + on success activate every already-connected agent. Branches across four + paths: single (one named tool from PyPI with an optional four-form + version), bulk (every tool declared in config.tools in a single pip + invocation), empty (Nothing to install), and LOCAL (one pip-installable + local directory). After a successful pip in single, local, and bulk + mode the post-install hooks run, then the activation re-sync unless + `no_connect` is set. Propagates pip's returncode as the exit code + whenever pip is invoked or re-sync is skipped; a hook failure is a + user-facing non-zero exit; otherwise propagates the re-sync outcome + (first non-zero per-agent failure). + + `exit_code`: pip's outcome when pip failed or re-sync is skipped; a + user-facing non-zero code when a hook raised; the first non-zero + per-agent re-sync failure otherwise; 0 in the empty path. `ctx`: Click execution context used to control process exit codes. - `name`: optional tool identifier without the goga-tool- / goga_tool_ prefix - (CLI positional argument). When present, the single path runs and the - config is ignored. When absent, the bulk/empty path runs from cfg.tools. + `name`: optional tool identifier without the goga-tool- / goga_tool_ + prefix (CLI positional argument). When present, the single path runs + and the config is ignored. When absent, the bulk/empty path runs from + cfg.tools. `sudo`: when True, run pip under sudo with HOME preserved (Unix-only). - `version`: optional version-form string in the four-form grammar. Used by - the single path only (ignored in the bulk path). Resolved by - `resolve_version`; operator-prefixed or malformed forms raise ValueError - at resolution time. The CLI flag binding the callback's `version` - parameter MUST expose both forms: the primary long form --version and - the secondary short alias -v — Click receives them on a single Option - so both deserialise into the same parameter. - `local`: optional path to a pip-installable local directory. When set (and - `name` is None), the LOCAL path runs: pip installs from the local - directory instead of resolving goga-tool-<name> from PyPI. Mutually - exclusive with `name`; `version` is rejected in this mode. - `no_connect`: when True, skip the post-install activation re-sync — the - command performs the pip install only. Defaults to False (re-sync enabled). + Hooks and activation never run under sudo. + `version`: optional version-form string in the four-form grammar. Used + by the single path only (ignored in the bulk path). Resolved by + `resolve_version`; operator-prefixed or malformed forms raise + ValueError at resolution time. The CLI flag binding the callback's + `version` parameter MUST expose both forms: the primary long form + --version and the secondary short alias -v — Click receives them on a + single Option so both deserialise into the same parameter. + `local`: optional local source value: a pip-installable directory path + optionally followed by the :<tool-name> suffix naming the tool whose + post-install hook should run. When set (and `name` is None), the + LOCAL path runs: pip installs from the local directory instead of + resolving goga-tool-<name> from PyPI. Mutually exclusive with `name`; + `version` is rejected in this mode. Without the suffix no hook runs + for the local install (a warning names the suffix as the way to + enable it). + `no_connect`: when True, skip the post-install activation re-sync only + — the hooks still run after a successful pip. Defaults to False. Algorithm: 0. VALIDATIONS (first, before any path): @@ -98,121 +117,251 @@ Annotations: | 0.2. If `local` is not None AND `version` is not None -> raise a user-facing ClickException (--version applies to the SINGLE path only and is meaningless for a local source); exit non-zero. + 0.3. If the `local` value carries the :<tool-name> suffix with an + empty name, a path separator, or another colon -> raise a + user-facing ClickException (malformed suffix); exit non-zero. 1. If `name` is not None → SINGLE PATH: 1.1. Resolve `version` via `resolve_version`; on rejection, surface a user-facing CLI exception with a non-zero exit - 1.2. Compose the package identifier from `name` and the resolved specifier - (empty when `resolve_version` returned None) - 1.3. Issue one pip install invocation against the current interpreter with - the composed identifier and an upgrade request; apply sudo with HOME - preservation when `sudo` is set - 1.4. Propagate pip's outcome as the pip exit code + 1.2. Compose the package identifier from `name` and the resolved + specifier (empty when `resolve_version` returned None) + 1.3. Issue one pip install invocation against the current + interpreter with the composed identifier and an upgrade + request; apply sudo with HOME preservation when `sudo` is set + 1.4. Propagate pip's outcome as the pip exit code; hooks target + list = [`name`] 2. Else if `local` is not None -> LOCAL PATH: - 2.1. Issue one pip install invocation against the current interpreter with - the local directory path as the install target and an upgrade request; - apply sudo with HOME preservation when `sudo` is set. Do NOT validate - path existence at the CLI layer — pip owns that error and its return - code is translated unchanged. - 2.2. Propagate pip's outcome as the pip exit code. + 2.1. Issue one pip install invocation against the current + interpreter with the local directory path as the install target + and an upgrade request; apply sudo with HOME preservation when + `sudo` is set. Do NOT validate path existence at the CLI layer + — pip owns that error and its return code is translated + unchanged. + 2.2. Propagate pip's outcome as the pip exit code. Hooks target + list = the <tool-name> suffix value, or an empty list with a + warning that the hook step is skipped and the :<tool-name> + suffix enables it. 3. If `name` is None → BULK / EMPTY PATH: - 3.1. Load configuration via `load_project_config`; the result is a `ProjectConfig` instance. - Loader exceptions (OSError, KeyError, ValueError, yaml.YAMLError) - MUST be wrapped into a user-facing ClickException (non-zero exit) — - never let a raw loader exception surface as a traceback. + 3.1. Load configuration via `load_project_config`; the result is a + `ProjectConfig` instance. Loader exceptions + (OSError, KeyError, ValueError, yaml.YAMLError) MUST be wrapped + into a user-facing ClickException (non-zero exit) — never let a + raw loader exception surface as a traceback. 3.2. Read the tools mapping from cfg.tools (treat None as empty) - 3.3. If the mapping is empty → EMPTY PATH: print "Nothing to install" to - stdout and exit 0 without invoking pip and without activation - 3.4. Else → BULK PATH: for each (tool_name, form) preserving insertion - order, resolve the form via `resolve_version` (surface rejection as a - user-facing CLI exception), compose the identifier, collect it; issue - exactly one pip install invocation with every collected identifier - and an upgrade request; apply sudo with HOME preservation when `sudo` - is set; propagate pip's outcome as the pip exit code - 4. ACTIVATION (single, local, and bulk paths only, after pip): - (LOCAL participates by the same rules as single/bulk) - 4.1. If `no_connect` is True → keep the pip exit code and stop - 4.2. If the pip exit code is non-zero → keep the pip exit code and stop - 4.3. Otherwise call `resync_registered_agents` with the current user's goga - home (~/.goga); the routine reads ~/.goga/connect.yml and re-activates - every recorded agent - 4.4. The final exit code becomes the re-sync outcome (0 on full success or - a missing/empty registry; otherwise the first non-zero per-agent failure) - - Apply `click` for the command shape, the optional positional argument, the - flags, the options (including the --local/-l secondary short alias on a - single Option, like --version/-v), the mutex/version ClickException, - ctx.pass_context, ctx.exit, click.echo for the empty-path message, and - exit-code propagation. Apply `convention` for the CLI command docstring rule - (--help rendered verbatim by Click; omit Args/Returns/Raises), import - discipline, and structured logging. Apply `project-configuration` for - `load_project_config` semantics and the no-validation contract on cfg.tools; - `project-configuration` is NOT used by the LOCAL path (config is ignored, - same as SINGLE). + 3.3. If the mapping is empty → EMPTY PATH: print "Nothing to + install" to stdout and exit 0 without invoking pip, without + hooks, and without activation + 3.4. Else → BULK PATH: for each (tool_name, form) preserving + insertion order, resolve the form via `resolve_version` (surface + rejection as a user-facing CLI exception), compose the + identifier, collect it; issue exactly one pip install + invocation with every collected identifier and an upgrade + request; apply sudo with HOME preservation when `sudo` is set; + propagate pip's outcome as the pip exit code. Hooks target list + = the config keys in YAML order. + 4. HOOKS (single, local, and bulk paths only, after a successful pip — + pip exit 0): + 4.1. Run `run_install_hooks` with the path's hooks target list + 4.2. A hook exception -> raise a user-facing ClickException carrying + the tool name and the hook message; exit non-zero; the pip + package stays installed; the activation re-sync does not run; + in bulk the sequence stops at the first failing hook — the + remaining tools' hooks are not called + 5. ACTIVATION (single, local, and bulk paths, after pip and hooks): + 5.1. If `no_connect` is True → keep the pip exit code and stop (the + hooks already ran in step 4) + 5.2. If the pip exit code is non-zero → keep the pip exit code and + stop (hooks did not run) + 5.3. Otherwise call `resync_registered_agents` with the current + user's goga home (~/.goga); the routine reads + ~/.goga/connect.yml and re-activates every recorded agent + 5.4. The final exit code becomes the re-sync outcome (0 on full + success or a missing/empty registry; otherwise the first + non-zero per-agent failure) + + Apply `click` for the command shape, the optional positional argument, + the flags, the options (paired long/short forms on a single Option), + the mutex/version ClickException, ctx.pass_context, ctx.exit, + click.echo for the empty-path message, and exit-code propagation. + Apply `convention` for the CLI command docstring rule (--help rendered + verbatim by Click; omit Args/Returns/Raises), import discipline, and + structured logging. Apply `project-configuration` for + `load_project_config` semantics and the no-validation contract on + cfg.tools; `project-configuration` is NOT used by the LOCAL path + (config is ignored, same as SINGLE). Requirements: - The single path MUST ignore cfg.tools entirely — name + flags fully determine the call - - The bulk path MUST issue exactly one pip invocation whose argv contains - every resolved goga-tool-<name><spec> in YAML order + - The bulk path MUST issue exactly one pip invocation whose argv + contains every resolved goga-tool-<name><spec> in YAML order - The empty path MUST print "Nothing to install" to stdout and exit 0 - without invoking pip and without activation + without invoking pip, without hooks, and without activation - The --version option is used by the single path only; the bulk path MUST NOT consult it - - The version flag MUST be registered with both the long form --version - and the short alias -v on the same Click Option (Click secondary - flag); both forms bind the callback's `version` parameter identically - - --sudo MUST apply sudo with HOME preservation to the (single) pip argv in - both single and bulk modes; activation never runs under sudo - - Activation MUST run only when pip succeeded (exit 0) in single or bulk - mode and `no_connect` is False; it MUST NOT run in the empty path or after - a non-zero pip - - The final exit code MUST equal the pip outcome when pip failed, when - `no_connect` is set, or in the empty path; otherwise it MUST equal the - activation re-sync outcome + - The version flag MUST be registered with both the long form + --version and the short alias -v on the same Click Option + - --sudo MUST apply sudo with HOME preservation to the (single) pip + argv in single, local, and bulk modes; hooks and activation never run + under sudo + - The `local` value grammar is <path> or <path>:<tool-name>; the suffix + tool name selects the facade module goga_tool_<tool-name> for the + hooks step; a malformed suffix (empty name, path separator, extra + colon) is a validation error before any pip + - The hooks step MUST run in every pip path after a successful pip, + regardless of `no_connect` — the flag suppresses only the activation + re-sync + - A hook failure MUST produce a user-facing non-zero exit carrying the + tool name and the hook message; the pip package MUST NOT be rolled + back; the re-sync MUST NOT run + - In bulk mode the hooks sequence MUST stop at the first failing hook — + the remaining tools' hooks are not called + - A local install without the suffix MUST log a warning naming the way + to enable the hook (the :<tool-name> suffix) + - The final exit code MUST equal the pip outcome when pip failed or + `no_connect` is set, a user-facing non-zero code when a hook raised, + or the empty-path 0; otherwise it MUST equal the activation re-sync + outcome - pip MUST be invoked through the current interpreter with an upgrade request present in every invocation - - The LOCAL path MUST install exactly one local directory via a single pip - invocation with an upgrade request; the local path replaces the PyPI source + - The LOCAL path MUST install exactly one local directory via a single + pip invocation with an upgrade request and MUST translate pip's + return code unchanged - `name` and --local MUST be mutually exclusive — combining them is a user-facing error (non-zero exit) - - --version MUST be rejected in the LOCAL path (non-zero exit); SINGLE only - - The LOCAL path MUST participate in post-install activation by the same - rules as SINGLE/BULK: activation runs when pip succeeded (exit 0) and - `no_connect` is False; --no-connect suppresses it - - The LOCAL path MUST translate pip's return code unchanged (including pip's - own errors for a missing/non-installable path) - - --sudo MUST apply sudo with HOME preservation to the single pip argv in - the LOCAL path; activation never runs under sudo - - The --local/-l flag MUST be registered with both the long form --local - and the short alias -l on the same Click Option + - --version MUST be rejected in the LOCAL path (non-zero exit); SINGLE + only + - The --local/-l flag MUST be registered with both forms on the same + Click Option Constraints: - - Do NOT validate, parse, or modify `version` outside `resolve_version` — - `resolve_version` is the sole owner of the grammar and the single point - where malformed forms raise ValueError - - Do NOT accept operator-prefixed forms in either --version or cfg.tools — - they raise ValueError at resolution time + - Do NOT validate, parse, or modify `version` outside `resolve_version` + - Do NOT accept operator-prefixed forms in either --version or + cfg.tools - Do NOT install packages sequentially in the bulk path — all resolved packages MUST land in one pip argv - Do NOT auto-select sudo — the caller opts in via --sudo - - Do NOT run activation under sudo — activation operates on the local user - home; only pip honors --sudo + - Do NOT run hooks or activation under sudo — both operate on the local + user context; only pip honors --sudo - Do NOT write ~/.goga/connect.yml directly — activation goes through - `resync_registered_agents`; this command never writes the registry + `resync_registered_agents` - Do NOT probe whether the package is already installed — the upgrade request handles it - - Do NOT chunk the bulk argv — even a long argv is issued as a single pip - invocation + - Do NOT chunk the bulk argv — even a long argv is issued as a single + pip invocation - On Windows, --sudo is unavailable (sudo is Unix-only) - - Do NOT install in editable mode (-e) in the LOCAL path — install the - local directory the same way SINGLE/BULK install from PyPI (regular - install with -U) + - Do NOT install in editable mode (-e) in the LOCAL path - Do NOT validate the local path's existence at the CLI layer — let pip surface the error and translate its exit code - - Do NOT resolve a goga-tool-<name> identifier in the LOCAL path — the local - directory is the install target as-is - - Do NOT consult `version` in the LOCAL path — it is rejected at validation - time + - Do NOT guess the tool name from the local path — no suffix, no hook + - Do NOT run the hook under a different user or re-execute it — goga + hands the initiating user over as a string only + - Do NOT run install hooks for uninstall or upgrade — the hook belongs + to the install command alone + - Do NOT roll the pip install back when a hook fails + +"resolve_initiating_user() -> user: str": + location: hook.py + annotations: | + Resolve the user who initiated the installation — the actual person, + not the root account the installer may run under. + + `user`: initiating user name + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. SUDO_USER is set and non-empty in the environment -> return its + value (the installation runs under sudo) + 2. Otherwise -> return the operating-system user name of the current + process + + Requirements: + - The value is only handed to the hook — the tool decides what to do + with it (ownership changes, per-user configuration); goga never + re-executes the hook on behalf of another user + + Constraints: + - Do not substitute a fallback name when a source cannot resolve — the + operating-system user name is the canonical answer + +"run_install_hooks(tools: list[str]) -> none: None": + location: hook.py + annotations: | + Run the post-install hook for every freshly installed tool. + + `tools`: names of the installed tools in installation order + + Apply the `convention` practice for docstring style, intra-package + imports, and structured logging. + + Algorithm: + 1. An empty `tools` list -> return at once (no user resolution, no log + lines) + 2. Resolve the initiating user once via `resolve_initiating_user` + 3. For each name in `tools` in order: run `call_install_hook` with the + name and the user; log the outcome — hook invoked or skipped + 4. A hook exception is wrapped in a RuntimeError carrying the tool name + ("install hook for tool '<name>' failed: <message>") with the original + exception as its cause; the loop stops and the hooks of the remaining + tools are not run. A failure of the user-resolution step itself + propagates as-is — it is not a hook failure and carries no tool + context + + Requirements: + - One initiating user for the whole call — every hook of the run sees + the same value + - An empty list is a quiet no-op + + Constraints: + - Do not swallow or downgrade a hook exception — the caller turns it + into the command failure + - Do not run the agent re-sync here — activation stays the command's + own step + +"call_install_hook(tool: str, user: str) -> invoked: bool": + location: hook.py + annotations: | + Run the post-install hook of one installed tool when it provides one. + + `tool`: tool name without the goga_tool_ prefix + `user`: initiating user to inject when the hook asks for it + `invoked`: True when the hook ran, False when it was skipped + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Normalize `tool` into the facade module identifier goga_tool_<name>: + every hyphen and dot becomes an underscore and the result is + lowercased (the pip identifier is hyphenated while the on-disk + top-level module is underscored, and the import lookup is + case-sensitive). Import the module; when the facade itself is missing + (ModuleNotFoundError naming exactly that module) -> False (a quiet + skip — a package without the facade convention is normal). A + ModuleNotFoundError naming a DIFFERENT module means the facade exists + but its own imports are broken -> propagate (a failure, not a skip) + 2. A callable install on the facade is absent -> False (a quiet skip — + the hook is optional for every tool) + 3. The hook signature declares a keyword-capable parameter named user + -> call install(user=user); otherwise call install() + 4. An exception raised by the hook propagates unchanged + 5. A completed call -> True + + Requirements: + - The injection follows the signature-projection mechanism: only a + declared keyword-capable user parameter receives the value — the + offered-name set is the single source of the opt-in + - A skip is silent for the user and visible in the debug log only + + Constraints: + - Do not pass any argument other than user — the hook contract is a + bare call or a user-only call + - Do not isolate or sandbox the hook — it runs with the trust level of + the installed package + - Do not treat a missing facade module as an error — it is a normal + skip, not a hook failure "uninstall(ctx: click.Context, name: str, sudo: bool = False, yes: bool = False, target_user: str | None = None) -> exit_code: int": location: uninstall.py @@ -329,7 +478,7 @@ CreatedAt: 13/07/26 Description: | Lifecycle of goga-tool packages in the current runtime interpreter: - installation of one, many, or a local source via pip, and removal of one - tool via a confirmed forced pip uninstall. Every successful pip outcome is - followed by the shared agent re-sync, and pip's exit code always propagates - unchanged. + installation of one, many, or a local source via pip — each successful + install followed by the tool's optional post-install hook and the shared + agent re-sync — and removal of one tool via a confirmed forced pip + uninstall. pip's exit code always propagates unchanged. diff --git a/goga/commands/install/__init__.py b/goga/commands/install/__init__.py index fad72118..fa1ca7d5 100644 --- a/goga/commands/install/__init__.py +++ b/goga/commands/install/__init__.py @@ -1,6 +1,7 @@ """Install command cell — CLI wrappers for the goga tool package lifecycle.""" +from .hook import call_install_hook, resolve_initiating_user, run_install_hooks from .install import install from .uninstall import uninstall -__all__: list[str] = ["install", "uninstall"] +__all__: list[str] = ["call_install_hook", "install", "resolve_initiating_user", "run_install_hooks", "uninstall"] diff --git a/goga/commands/install/hook.py b/goga/commands/install/hook.py new file mode 100644 index 00000000..687d42f9 --- /dev/null +++ b/goga/commands/install/hook.py @@ -0,0 +1,146 @@ +"""Post-install hook routines for ``goga install``. + +The three hook routines declared in the cell CODEMANIFEST with ``location: +hook.py``: the initiating-user resolver (the actual person behind a possibly +sudo-ed install), the single-tool hook invocation (a dynamic +``goga_tool_<tool>`` facade import with signature-projected ``user`` +injection), and the sequential runner (one initiating user per run, stop at +the first failure). The hook is optional per tool: a missing facade module or +a missing ``install`` callable is a quiet skip — silent for the user and +visible in the debug log only. +""" + +from __future__ import annotations + +import getpass +import importlib +import inspect +import logging +import os + +logger = logging.getLogger(__name__) + +# The signature projection's opt-in set: only a ``user`` parameter declared +# POSITIONAL_OR_KEYWORD or KEYWORD_ONLY receives the value — ``**kwargs`` +# alone is NOT an opt-in (the offered-name set is the single source). +_KEYWORD_CAPABLE_KINDS = frozenset({inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY}) + + +def resolve_initiating_user() -> str: + """Resolve the user who initiated the installation. + + The actual person, not the root account the installer may run under: a + set and non-empty ``SUDO_USER`` environment variable wins (the + installation runs under sudo and sudo recorded the caller); otherwise + the operating-system user name of the current process is the canonical + answer. + + Returns: + The initiating user name. + + Raises: + OSError: when the operating-system identity cannot be resolved — the + failure propagates, no fallback name is invented. + KeyError: when the user database has no entry for the current + process — propagates for the same reason. + """ + sudo_user = os.environ.get("SUDO_USER") + if sudo_user: + return sudo_user + + return getpass.getuser() + + +def call_install_hook(tool: str, user: str) -> bool: + """Run the post-install hook of one installed tool when it provides one. + + Imports the tool facade ``goga_tool_<tool>`` and calls its optional + ``install`` callable. The identifier is normalized to a module name first — + hyphens and dots become underscores and the result is lowercased, so the + canonical hyphenated tool name (``hello-world``) imports + ``goga_tool_hello_world``, the spelling pip lays out on disk (pip resolves + distribution names case-insensitively, the import lookup does not). The + injection follows the signature projection: + only a declared keyword-capable ``user`` parameter receives the value — a + positional-only ``user`` or a bare ``**kwargs`` is not an opt-in and the + hook is called without arguments. No argument other than ``user`` is + ever passed, and the hook runs unsandboxed, with the trust level of the + installed package. + + Args: + tool: Tool name without the ``goga_tool_`` prefix. + user: Initiating user to inject when the hook asks for it. + + Returns: + True when the hook ran; False when it was skipped (a missing facade + module or a missing/non-callable ``install`` — a quiet skip). + + Raises: + ModuleNotFoundError: when the facade exists but its own imports are + broken — the missing module is a different one (``exc.name`` + differs), a failure rather than a skip. + Exception: whatever the hook itself raises, propagated unchanged. + """ + # The pip-style tool identifier is hyphenated (``hello-world``) while the + # installed top-level module is underscored (``goga_tool_hello_world``) — + # the same duality `goga connect` normalizes for pipeline namespacing. + # pip resolves distribution names case-insensitively but the import lookup + # is case-sensitive, so the identifier is lowercased too (the canonical + # tool spelling is lowercase-hyphenated). Without this a case-variant or + # multi-word identifier makes the facade import miss and the hook degrade + # to the quiet-skip path. + module_name = f"goga_tool_{tool.replace('-', '_').replace('.', '_').lower()}" + + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError as exc: + if exc.name == module_name: + return False + raise + + install = getattr(module, "install", None) + if install is None or not callable(install): + return False + + user_parameter = inspect.signature(install).parameters.get("user") + if user_parameter is not None and user_parameter.kind in _KEYWORD_CAPABLE_KINDS: + install(user=user) + else: + install() + + return True + + +def run_install_hooks(tools: list[str]) -> None: + """Run the post-install hook for every freshly installed tool. + + Resolves the initiating user exactly once — every hook of the run sees + the same value — then invokes each tool's hook in installation order. + A hook failure stops the loop: the exception is wrapped with the tool's + name as context and the hooks of the remaining tools are not run. An + empty list is a quiet no-op: no user resolution, no log lines. The agent + re-sync never runs here; activation stays the command's own step. + + Args: + tools: Names of the installed tools in installation order. + + Raises: + RuntimeError: a hook raised — ``install hook for tool '<tool>' + failed: <message>``, with the original exception as its cause. + Exception: a failure of the user-resolution step propagates as-is — + it is not a hook failure and carries no tool context. + """ + if not tools: + return + + user = resolve_initiating_user() + + for tool in tools: + try: + invoked = call_install_hook(tool, user) + except Exception as exc: + raise RuntimeError(f"install hook for tool {tool!r} failed: {exc}") from exc + if invoked: + logger.info("install hook invoked", extra={"tool": tool}) + else: + logger.debug("install hook skipped", extra={"tool": tool}) diff --git a/goga/commands/install/install.py b/goga/commands/install/install.py index e1b7fd16..e5df9a9e 100644 --- a/goga/commands/install/install.py +++ b/goga/commands/install/install.py @@ -11,6 +11,7 @@ from ...config import load_project_config from ...connect import resync_registered_agents from ...version import resolve_version +from .hook import run_install_hooks logger = logging.getLogger(__name__) @@ -76,26 +77,139 @@ def _resolve_pkg(name: str, form: str | None) -> str: return f"goga-tool-{name}" + (spec or "") -def _after_pip(pip_rc: int, no_connect: bool) -> int: - """Decide the final exit code after pip (Algorithm step 4 — ACTIVATION). +def _parse_local(value: str) -> tuple[str, str | None]: + """Split a ``--local`` value into its path and optional tool-name suffix. - Activation (the post-install agent re-sync) runs only when pip succeeded - (``pip_rc == 0``) and the caller did not opt out with ``no_connect``. When - skipped — because pip failed or ``no_connect`` is set — pip's own outcome is - the final exit code. The re-sync targets the current user's ``~/.goga`` and - is the single path through which activation runs; this routine never writes - ``connect.yml`` and never runs under sudo. + The grammar is ``<path>`` or ``<path>:<tool-name>`` — the FIRST colon is + the separator. The suffix names the tool whose post-install hook runs for + the local install; without it no hook runs. A malformed suffix (an empty + name, a path separator, or another colon — e.g. a Windows drive path + misread as a suffix) is rejected here, before any pip. + + Args: + value: The raw ``--local`` option value. + + Returns: + The local directory path and the tool name from the suffix, or None + when the value carries no ``:`` suffix. + + Raises: + click.ClickException: when the suffix is malformed — a user-facing + error (exit 1) raised before any pip invocation. + """ + path, sep, tool = value.partition(":") + if sep == "": + return value, None + if tool == "": + raise click.ClickException(f"malformed --local value {value!r}: empty tool name after ':'") + if "/" in tool or "\\" in tool: + raise click.ClickException(f"malformed --local value {value!r}: tool name must not contain a path separator") + if ":" in tool: + raise click.ClickException(f"malformed --local value {value!r}: tool name must not contain ':'") + + return path, tool + + +def _local_hook_targets(local_path: str, local_tool: str | None) -> list[str]: + """Compose the LOCAL path's hook-target list (Algorithm step 2.2). + + The ``:<tool-name>`` suffix names the tool whose post-install hook runs for + the local install. A value without the suffix gets an empty list — no tool + name is guessed from the path — and a warning naming the suffix as the way + to enable the hook. + + Args: + local_path: The local directory path from the ``--local`` value. + local_tool: The tool name from the ``:<tool-name>`` suffix, or None + when the value carries no suffix. + + Returns: + The hook-target list handed to the hooks step. + """ + if local_tool is None: + logger.warning( + "install hook skipped for local source", + extra={"path": local_path, "hint": "pass :<tool-name> to enable the post-install hook"}, + ) + return [] + + return [local_tool] + + +def _resolve_bulk_pkgs(tools: dict[str, str]) -> list[str]: + """Resolve every declared tool's identifier, preserving insertion order. + + Each ``(tool_name, form)`` pair from the config's ``tools`` mapping is + resolved through ``resolve_version``; a rejected form is a user-facing + ``click.ClickException`` naming the offending tool, raised before any pip + invocation. + + Args: + tools: The config tools mapping, in YAML insertion order. + + Returns: + The composed ``goga-tool-<name><spec>`` identifiers. + + Raises: + click.ClickException: when a tool's version form is rejected. + """ + pkgs: list[str] = [] + + for tool_name, form in tools.items(): + try: + pkgs.append(_resolve_pkg(tool_name, form)) + except ValueError as exc: + raise click.ClickException(f"invalid version for tool {tool_name!r}: {exc}") from exc + + return pkgs + + +def _after_pip(pip_rc: int, hook_targets: list[str], no_connect: bool) -> int: + """Run the post-install hooks and activation after pip (Algorithm steps 4 and 5). + + Hooks run only when pip succeeded (``pip_rc == 0``): every freshly + installed tool's optional ``install`` hook is invoked with one initiating + user, in installation order, stopping at the first failure — a hook failure + is a user-facing ``click.ClickException`` (exit 1) that leaves the pip + package in place and never reaches activation. Activation (the post-install + agent re-sync) follows unless the caller opted out with ``no_connect``; + the flag suppresses only the re-sync — the hooks already ran. When a step + is skipped, pip's own outcome is the final exit code. The re-sync targets + the current user's ``~/.goga`` and is the single path through which + activation runs; this routine never writes ``connect.yml`` and never runs + under sudo. Args: pip_rc: The exit code returned by the pip invocation. + hook_targets: Names of the freshly installed tools in installation + order — the per-path hook target list (single ``[name]``, local + ``[<tool>]`` or empty, bulk config keys). no_connect: When True, skip activation and keep ``pip_rc`` verbatim. Returns: - ``pip_rc`` when activation is skipped, otherwise the re-sync outcome - (0 on full success or a missing/empty registry, else the first + ``pip_rc`` when hooks or activation are skipped, otherwise the re-sync + outcome (0 on full success or a missing/empty registry, else the first non-zero per-agent failure). + + Raises: + click.ClickException: when the hooks step fails — a wrapped hook + failure carries the tool name and hook message; an unwrapped + hook-step failure (e.g. identity resolution) surfaces its own + message. ``BaseException`` (Ctrl-C, SystemExit) is never caught. """ - if no_connect or pip_rc != 0: + if pip_rc != 0: + # Nothing runs after a failed pip — no hooks, no re-sync. + return pip_rc + + try: + run_install_hooks(hook_targets) + except Exception as exc: + # The wrapped RuntimeError carries the tool name; an unwrapped + # hook-step failure (identity resolution) surfaces here equally clean. + raise click.ClickException(str(exc)) from exc + + if no_connect: + # The flag suppresses only the re-sync; the hooks already ran. return pip_rc return resync_registered_agents(Path.home() / ".goga") @@ -115,7 +229,11 @@ def _after_pip(pip_rc: int, no_connect: bool) -> int: "-l", "local", default=None, - help="Path to a pip-installable local directory (local mode); mutually exclusive with name; --version is rejected", + help=( + "Path to a pip-installable local directory, optionally :<tool-name> " + "to name the tool whose install hook runs; mutually exclusive with " + "name; --version is rejected" + ), ) @click.option( "--no-connect", @@ -141,7 +259,9 @@ def install( # noqa: PLR0913, PLR0917 — Click callback arity is contract-mand * SINGLE (``name`` set): install ``goga-tool-<name><spec>`` resolved from ``--version`` in a single pip call. * LOCAL (``--local <path>`` set): pip-install a local directory; mutually - exclusive with ``name``, and ``--version`` is rejected. + exclusive with ``name``, and ``--version`` is rejected. The value may + carry a ``:<tool-name>`` suffix naming the tool whose post-install + hook runs; without it no hook runs for the local install. * BULK (``name`` omitted, ``.goga/config.yml`` lists ``tools:``): install every ``goga-tool-<tool><spec>`` declared in the config in a single pip call. @@ -157,6 +277,14 @@ def install( # noqa: PLR0913, PLR0917 — Click callback arity is contract-mand if local is not None and version is not None: raise click.ClickException("--version is not supported with --local") + local_path: str | None = None + local_tool: str | None = None + + if local is not None: + # 0.3. VALIDATION — the :<tool-name> suffix grammar; a malformed suffix + # aborts before any pip, a well-formed one names the hook target. + local_path, local_tool = _parse_local(local) + if name is not None: # SINGLE PATH — install one tool, grammar-resolving --version. try: @@ -165,13 +293,14 @@ def install( # noqa: PLR0913, PLR0917 — Click callback arity is contract-mand raise click.ClickException(f"invalid --version value {version!r}: {exc}") from exc pip_rc = _run_pip(_pip_argv([pkg], sudo), sudo) - ctx.exit(_after_pip(pip_rc, no_connect)) + ctx.exit(_after_pip(pip_rc, [name], no_connect)) if local is not None: # LOCAL PATH — pip-install a local directory. pip owns the missing-path # error (no CLI-level existence check), and -U is always requested (never -e). - pip_rc = _run_pip(_pip_argv([local], sudo), sudo) - ctx.exit(_after_pip(pip_rc, no_connect)) + hook_targets = _local_hook_targets(local_path, local_tool) + pip_rc = _run_pip(_pip_argv([local_path], sudo), sudo) + ctx.exit(_after_pip(pip_rc, hook_targets, no_connect)) # BULK / EMPTY PATH — driven by .goga/config.yml. try: @@ -187,12 +316,6 @@ def install( # noqa: PLR0913, PLR0917 — Click callback arity is contract-mand click.echo("Nothing to install") ctx.exit(0) - pkgs: list[str] = [] - for tool_name, form in tools.items(): - try: - pkgs.append(_resolve_pkg(tool_name, form)) - except ValueError as exc: - raise click.ClickException(f"invalid version for tool {tool_name!r}: {exc}") from exc - + pkgs = _resolve_bulk_pkgs(tools) pip_rc = _run_pip(_pip_argv(pkgs, sudo), sudo) - ctx.exit(_after_pip(pip_rc, no_connect)) + ctx.exit(_after_pip(pip_rc, list(tools.keys()), no_connect)) diff --git a/goga/commands/pipeline/.usages/pipeline-command.md b/goga/commands/pipeline/.usages/pipeline-command.md index ea6bb1a2..88b0909c 100644 --- a/goga/commands/pipeline/.usages/pipeline-command.md +++ b/goga/commands/pipeline/.usages/pipeline-command.md @@ -3,7 +3,7 @@ `goga pipeline` is a single Click command with five explicit forms. Every form launches the goga Docker container and invokes python -m goga.pipeline inside it. The host never reads pipeline files directly — the runtime -boundary to goga/pipeline is docker. +boundary to the in-container pipeline is docker. ## Forms @@ -25,6 +25,8 @@ exit 1). `--info` is a modifier, not a mode: without a name and without |---|---|---| | -l / --list | flag | select the listing forms | | -i / --info | flag | show instead of act (overview with --list, card with NAME) | +| -t / --topic ID | str | bring the repository onto the requested work (branch name, topic slug, or prefix) before the run, creating it when nothing hosts it; run form only | +| --todo | flag | open the external editor with the topic's todo.md after the switch or the fast creation (run form only; a clean error without --topic and on a non-terminal before any git or docker activity; no short form — -t stays with --topic) | | -w / --workflow NAME | str | apply an explicit workflow (run and card); the file must exist (early host validation) | | --no-workflow | flag | disable workflow resolution (run and card) | | -p / --parallel N | int | max concurrently executing stages; run only | @@ -32,10 +34,39 @@ exit 1). `--info` is a modifier, not a mode: without a name and without | -c / --clean | flag | wipe persistent afm state before launch; run only | | -u / --update | flag | refresh the image before the flat list and the run; no-op in the info forms | +## Continuing or starting work + + goga pipeline development --topic history-com + goga pipeline development -t release-1-3-0 + goga pipeline refinement -t prune-history-and-new-status + goga pipeline development --topic history-com --todo + goga pipeline refinement -t prune-history-and-new-status --todo + +Brings the repository onto the requested work — an exact branch name, an +exact topic slug, or their prefix — and then launches the usual run. When +nothing hosts the identifier, fresh work is created instead: the branch +named as entered and the topic directory of the year +(`Created branch <name> and topic <year>/<slug>`). The switch or creation +completes on the host before any docker activity; a repeated invocation +already on the host continues without switching. The flat list, overview, +and card forms silently ignore -t. Several candidates without a terminal, a +dirty working tree on a switch, or an unusable (empty-slug) or occupied +name without a terminal is a clean error before any launch. + +With --todo the editor opens with the topic's todo.md after the +repository is on the work: the existing content when the topic has a +todo, an empty entry otherwise; a branch without a topic gets its +topic directory created first — the fast process is never interrupted. +Saving overwrites todo.md without a commit; an empty or unchanged file +leaves it untouched. Without a terminal --todo is a clean error before +any git or docker activity, and so is --todo without --topic — the entry +needs requested work; --list and --info forms ignore the flag +silently. + ## Flag behavior in the list/info forms - Ignored (no-op, no side effects): `-e/--env`, `--proxy`, `-c/--clean`, - `-s/--skip`, `-p/--parallel`, `--add-host`. + `-s/--skip`, `-p/--parallel`, `--add-host`, `-t/--topic`, `--todo`. - `-u/--update`: works in `--list` without `--info`; no-op in both `--info` forms. - `-w/--workflow` and `--no-workflow`: validated as usual (exclusivity and, @@ -55,21 +86,21 @@ exit 1). `--info` is a modifier, not a mode: without a name and without ## -p vs docker -p The user-facing -p/--parallel is a Click option. The Docker port-publish --p <port>:<port> is an internal translated docker token assembled by -run_pipeline_container/DockerRunner from the allocated port (run form +-p <port>:<port> is an internal translated docker token assembled by the +run launcher from the allocated port (run form only). Different namespaces (Click CLI vs docker run argv) — no collision. The user never authors the docker -p. ## Threading chains goga pipeline NAME → run (full shape) + goga pipeline NAME -t feat/x → switch-or-create → run (full shape) goga pipeline --list → minimal shape: list goga pipeline --list --info → minimal shape: list --info goga pipeline NAME --info → minimal shape: run NAME --info [-w WF | --no-workflow] goga pipeline NAME -p N → docker run … -m goga.pipeline run NAME --port PORT --parallel N - → pipeline_cli → run_pipeline(parallel=N) → run_flow(max_parallel=N) - → afm run --port PORT --max-parallel N <flow> + → the in-container run launches afm bounded to N concurrent stages Absent ⇒ parallel=None ⇒ no in-container --parallel ⇒ afm unbounded. diff --git a/goga/commands/pipeline/CODEMANIFEST b/goga/commands/pipeline/CODEMANIFEST index e4674131..8e40ac35 100644 --- a/goga/commands/pipeline/CODEMANIFEST +++ b/goga/commands/pipeline/CODEMANIFEST @@ -1,4 +1,9 @@ Imports: + - Types: + - ensure_topic + Usages: + - ensuring + From: goga/topics - Types: - ProjectConfig - load_project_config @@ -43,6 +48,11 @@ Usages: afm: .goga/usages/cooks/afm.md agent-wrappers: .goga/usages/cooks/agent-as-claude-wrappers.md docker-auth-mounts: .goga/usages/cooks/docker-auth-mounts.md + git: | + External git binary invoked via subprocess.run (check=True, + capture_output=True). Set GIT_TERMINAL_PROMPT=0 in the env to suppress + interactive prompts. Read-only inspection (git identity for the + container env-file). Mock the subprocess call in tests per `convention`. Annotations: | The `convention` practice is used for: @@ -103,14 +113,39 @@ Annotations: | check; the `docker-image-version` practice covers the image-side version probe. + The optional -t/--topic flag moves the repository onto the requested + work before the run form launches: the identifier resolves through + `ensure_topic` — an exact branch name, an exact topic slug, or their + prefix — and, when nothing hosts it, fresh work is created: the + branch named as entered, from the current HEAD, and the topic + directory of the year. The switch or the creation completes before + any docker activity. The optional --todo flag (no short form — -t + stays with --topic) requests the todo entry of the work: after the + switch onto hosted work, or after the fast creation, the external + editor opens with the topic's todo.md — the entry belongs to the + domain orchestration. Without an interactive terminal the flag is a + clean error before any git or docker activity. The listing and info + forms silently skip the whole topic procedure. + + Use the `ensuring` practice for the consumer patterns of the topics + facade used by the topic procedure — the identifier resolution and the + switch-or-create orchestration. + Use the `git` practice for the git identity read into the container + env-file. + Use the `click` practice for the -t/--topic option: a long form and a + short alias sharing a single Option, and exit-code propagation. + --- -"pipeline(ctx: click.Context, name: str | None, list_requested: bool, info: bool, extra_env: tuple[str, ...], proxy: str | None, add_host: tuple[str, ...], clean: bool, update: bool, workflow: str | None, no_workflow: bool, skip: tuple[str, ...], parallel: int | None)": +"pipeline(ctx: click.Context, name: str | None, list_requested: bool, info: bool, topic: str | None, todo: bool, extra_env: tuple[str, ...], proxy: str | None, add_host: tuple[str, ...], clean: bool, update: bool, workflow: str | None, no_workflow: bool, skip: tuple[str, ...], parallel: int | None)": location: pipeline.py annotations: | Single CLI command "goga pipeline" with five explicit forms. Every form launches the goga Docker container and invokes the in-container - entrypoint inside it; the host never reads pipeline files directly. + entrypoint inside it; the host never reads pipeline files directly. The + optional -t/--topic flag moves the repository onto the requested work + before the run form starts — creating it when nothing hosts the + identifier. `ctx`: Click execution context, used to propagate exit codes (per the `click` practice) @@ -121,6 +156,26 @@ Annotations: | `info`: flag from the --info/-i click option — a modifier meaning "show instead of act": with `list_requested` → the overview, with `name` → the card; on its own it selects no form. + `topic`: optional identifier from the -t/--topic option (a long form + and a short alias on a single click option) — a branch name, a + topic slug, or their prefix; the year scope is always the + current year. Run form only: brings the repository onto the + requested work via `ensure_topic` before any docker activity — + switching onto the hosting branch, or creating fresh work (the + branch named as entered and the topic directory of the year) + when nothing hosts the identifier. Silently ignored in the + flat list, overview, and card forms — not an error. + `todo`: flag from the --todo click option — request the todo entry + of the requested work; run form only: after the switch or + the fast creation of the topic procedure, the external + editor opens with the topic's todo.md inside `ensure_topic` + — a branch without a topic gets its topic directory created + first. Without an interactive terminal a clean error + precedes any git or docker activity. Given without `topic` + in the run form -> a clean error: the entry needs + requested work — before any git or docker activity. + Silently ignored in the flat list, overview, and card + forms — not an error. `extra_env`: raw KEY=VALUE strings from the repeatable -e/--env option, forwarded into the container env-file in the run form only. `proxy`: optional HTTP/HTTPS proxy URL from the --proxy option; when @@ -164,8 +219,8 @@ Annotations: | 2.2. `name` absent AND `list_requested` absent → the error 'Missing pipeline name. Use "goga pipeline --list" to list available pipelines, or provide a pipeline name.' to stderr, - exit 1, nothing to stdout, no image refresh and no first-run - build + exit 1, nothing to stdout, no topic procedure, no image + refresh and no first-run build 2.3. `workflow` provided AND `no_workflow` set → mutually-exclusive error (exit 1) 2.4. `workflow` provided → reject a name whose resolved path escapes @@ -174,7 +229,29 @@ Annotations: | resolve into the wider filesystem; then verify <cwd>/.goga/workflows/<workflow>.yml exists; a missing file is a clean error (exit 1) - 3. Dispatch by form: + 3. Topic procedure (run form only — `name` given, `list_requested` + and `info` unset): `todo` without `topic` is a clean error + before any git or docker activity — the entry needs requested + work. With + `topic` given, bring the repository onto the requested + work via `ensure_topic` with the `todo` flag — a switch onto the + hosting branch when one hosts the identifier, or the fast + creation when nothing does: the branch named as entered, from + the current HEAD — the configuration base is never read — and + the topic directory of the year, ensured on a branch without a + topic. Under `todo` the editor entry starts only after the + switch or the creation. Echo the single result line to stdout + once, immediately after the topic procedure and before the + step-4 dispatch; the forms that skip the procedure print no + topic line. Every git action and the entry happen on the host + before any docker activity. Several candidates without an + interactive terminal, a dirty working tree on a switch mutation, + an unusable or occupied name without a terminal, or `todo` + without an interactive terminal abort the command with a + non-zero exit before any image refresh, build, or launch. The + flat list, overview, and card forms skip the procedure silently + — passing -t or --todo there is not an error and has no effect. + 4. Dispatch by form: - flat list — `run_pipeline_info_container` with name=None, info=False; `update` applies (image refresh before the listing) - overview — `run_pipeline_info_container` with name=None, info=True; @@ -186,14 +263,35 @@ Annotations: | per the flag matrix) - run — resolve the proxy (CLI over config) and the hosts (CLI over config), then `run_pipeline_container` with the full argument set - 4. Propagate the returned exit code via the click context (per the + 5. Propagate the returned exit code via the click context (per the `click` practice) Requirements: - Expose -l/--list and -i/--info as click flags alongside the run options; long and short forms behave identically - - Every step-2 check runs before any docker activity — an argument-form - error never refreshes, builds, or launches an image + - Register -t/--topic with both forms on a single click Option — both + bind the `topic` parameter identically + - When the topic procedure ran, echo exactly one stdout line — the + result line of the topic procedure (a switch, a branch created from + a remote-tracking ref, the already-on-host confirmation, or the + fresh-work creation) — before the launch; no topic line in the + flat list, overview, and card forms + - Every step-2 check runs before any git or docker activity — an + argument-form error never switches a branch, refreshes, builds, or + launches an image + - The topic procedure (step 3) runs before any docker activity; a topic + error never launches an image + - The listing and info forms silently ignore -t/--topic — no message, + no side effects + - Without an interactive terminal, `todo` aborts the command before + any git or docker activity — the error precedes the topic + procedure + - The run form rejects `todo` without `topic` — a clean error + before any git or docker activity + - The listing and info forms silently ignore --todo — no message, + no side effects + - A repeated run already on the host continues without switching — the + call is idempotent - The listing and info forms silently ignore -e/--env, --proxy, -c/--clean, -s/--skip, -p/--parallel, and --add-host — no side effects; --clean deletes nothing @@ -217,6 +315,13 @@ Annotations: | - Do not validate the KEY=VALUE format, the "HOST:IP" format beyond the single-colon split, or the --skip stage names — forwarded as-is - Do not default or validate `parallel` + - Do not manage the stages of the hosting pipeline — goga prints no + stages and generates no skips; continuation belongs to the pipeline + itself + - Do not switch back to the previous branch after the launch — the run + finishes on the host branch + - Do not pass the topic name into the container — the container sees + the branch through the mounted project "run_pipeline_container(name: str, config: ProjectConfig, extra_env: tuple[str, ...], proxy: str | None, hosts: dict[str, str], clean: bool, update: bool, workflow: str | None, no_workflow: bool, skip: tuple[str, ...], parallel: int | None) -> exit_code: int": location: run_pipeline_container.py @@ -697,5 +802,7 @@ Description: | Host-side CLI wrapper cell for the single pipeline command. Every form — the flat list, the overview, the card, and the run — launches the goga Docker container and invokes the in-container pipeline entrypoint inside - it. The runtime boundary to the in-container pipeline is docker — this - cell has no Python Type Imports from it. + it; the run form can first bring the repository onto the requested work + (the -t/--topic switch-or-create) on the host. The runtime boundary to + the in-container pipeline is docker — this cell has no Python Type + Imports from it. diff --git a/goga/commands/pipeline/__init__.py b/goga/commands/pipeline/__init__.py index 790c197a..8b46e1bf 100644 --- a/goga/commands/pipeline/__init__.py +++ b/goga/commands/pipeline/__init__.py @@ -1,7 +1,17 @@ """Pipeline command cell — host-side launcher for the single goga pipeline command.""" from .pipeline import pipeline -from .run_pipeline_container import run_pipeline_container +from .run_pipeline_container import ( + clean_pipeline_runtime_dir, + resolve_pipeline_runtime_dir, + run_pipeline_container, +) from .run_pipeline_info_container import run_pipeline_info_container -__all__: list[str] = ["pipeline", "run_pipeline_container", "run_pipeline_info_container"] +__all__: list[str] = [ + "clean_pipeline_runtime_dir", + "pipeline", + "resolve_pipeline_runtime_dir", + "run_pipeline_container", + "run_pipeline_info_container", +] diff --git a/goga/commands/pipeline/pipeline.py b/goga/commands/pipeline/pipeline.py index 7c1bb591..449d9605 100644 --- a/goga/commands/pipeline/pipeline.py +++ b/goga/commands/pipeline/pipeline.py @@ -6,6 +6,7 @@ import yaml from ...config import load_project_config +from ...topics import ensure_topic from .run_pipeline_container import run_pipeline_container from .run_pipeline_info_container import run_pipeline_info_container @@ -28,6 +29,23 @@ default=False, help="Show pipeline descriptions (--list) or a pipeline card (NAME) instead of running", ) +@click.option( + "-t", + "--topic", + "topic", + type=str, + default=None, + help="Bring the repository onto the requested work before the run, " + "creating it when nothing hosts it (branch name, topic slug, or prefix; " + "run form only)", +) +@click.option( + "--todo", + "todo", + is_flag=True, + default=False, + help="Open the editor with the topic's todo.md after the switch or fast creation (run form only; requires --topic)", +) @click.option( "-e", "--env", @@ -93,11 +111,13 @@ help="cap concurrently executing stages (run mode only; threads to afm --max-parallel)", ) @click.pass_context -def pipeline( # noqa: C901, PLR0913, PLR0917 +def pipeline( # noqa: C901, PLR0912, PLR0913, PLR0917 ctx: click.Context, name: str | None, list_requested: bool, info: bool, + topic: str | None, + todo: bool, extra_env: tuple[str, ...], proxy: str | None, add_host: tuple[str, ...], @@ -119,6 +139,12 @@ def pipeline( # noqa: C901, PLR0913, PLR0917 With NAME and -i/--info: prints the pipeline card (name, description, stages in execution order) without running anything. + With -t/--topic: bring the repository onto the requested work (a branch + name, a topic slug, or their prefix) before the run — creating it when + nothing hosts the identifier. With --todo: open the editor with the + topic's todo.md after the switch or the fast creation (run form only; + requires --topic). + All forms launch the goga Docker container and delegate there — the host never reads pipeline files directly. """ @@ -188,7 +214,32 @@ def pipeline( # noqa: C901, PLR0913, PLR0917 if not workflow_path.exists(): raise click.ClickException(f"workflow '{workflow}' not found at {workflow_path}") - # Step 3 — dispatch. The info forms receive hosts from the config ONLY: + # Step 3 — topic procedure (run form only: `name` given, no --list, no + # --info). Every git action happens here on the host, AFTER every step-2 + # form check and BEFORE any docker activity — a form error or a topic + # error never refreshes, builds, or launches an image. `--todo` without + # `-t/--topic` is a clean error here: the entry needs requested work, and + # the abort precedes any git or docker activity. The flat list, overview, + # and card forms skip the procedure silently: passing -t or --todo there + # is not an error and has no effect. The single result line of + # `ensure_topic` (a switch, a fresh branch created from a remote-tracking + # ref, the already-on-host confirmation, or the creation of fresh work — + # branch plus topic directory — when nothing hosts the identifier) is + # echoed to stdout exactly once, immediately after the procedure and + # before the dispatch — and never forwarded into a launcher: the container + # sees the branch through the mounted project. Under --todo the external + # editor opens with the topic's todo.md inside the domain orchestration — + # after the switch or the fast creation, and only on an interactive + # terminal (the domain aborts cleanly otherwise, still before any docker + # activity). + if name is not None and not list_requested and not info: + if todo and topic is None: + raise click.ClickException("--todo acts only together with --topic") + if topic is not None: + line = ensure_topic(topic, todo) + click.echo(line) + + # Step 4 — dispatch. The info forms receive hosts from the config ONLY: # --add-host is a run-form surface (an info container is read-only, so # extra host aliases there would be dead weight) and is a deliberate no-op. info_hosts: dict[str, str] = {**config.pipeline.hosts} diff --git a/goga/commands/tool/CODEMANIFEST b/goga/commands/tool/CODEMANIFEST index 77491985..77d7677a 100644 --- a/goga/commands/tool/CODEMANIFEST +++ b/goga/commands/tool/CODEMANIFEST @@ -4,6 +4,9 @@ Imports: Usages: - loading From: goga/ast + - Usages: + - registering-statuses + From: goga/history Usages: click: .goga/usages/cooks/click.md @@ -13,6 +16,8 @@ Annotations: | The `conventions` practice governs codebase navigation, the REPL development cycle, debugging and testing, test infrastructure, and project-wide development principles. Apply `click` to implement the CLI command. The dispatcher resolves the tool package and forwards captured arguments together with the optional injections the tool entry point declares. + Use the `registering-statuses` practice for the topic-status registration + a tool package performs alongside its entry point. --- diff --git a/goga/commands/topics/.usages/topics-command.md b/goga/commands/topics/.usages/topics-command.md new file mode 100644 index 00000000..81c57f19 --- /dev/null +++ b/goga/commands/topics/.usages/topics-command.md @@ -0,0 +1,120 @@ +# commands/topics — the topics command group + +Consumer scenarios of the `goga topics` command group. For users who +manage work as topics: boarding, creating, and switching; for the command +facade that registers the group. + +The group scopes every subcommand to one year (--year/-y, default the +current year); the board subcommand reads remote-tracking refs with +--remote/-r and adds the todo column with --info/-i; the create subcommand +creates fresh work without switching by default, switches under +--switch/-s, and publishes under --publish/-p. + +## Boarding all work + + goga topics board + goga topics --year 2025 board + goga topics board --remote + goga topics board --info + +Prints a three-column table — topic, branch, statuses — with column and +row separators fitted to the terminal width. A row divider — the header +separator's dash run — closes every record, the last included; the wrapped +status lines of one record stay undivided. `--info/-i` adds the todo +column: topic, branch, todo, and statuses share the width — each of +the first three capped at a quarter of it minus the dividers — and the +todo cell shows the first line of the topic's `todo.md` that yields text +after leading `#` markers are stripped and the edges trimmed, read from the +ref trees without checkout (the current row from the working copy); a +topic without `todo.md` shows an empty cell. The todo column header is +`todo`. Overlong cells are truncated with an ellipsis. The current branch +row carries `*` in its topic cell; remote hosts keep their remote prefix. +A topic's statuses are all its maximal statuses, wrapped onto continuation +lines. An empty board prints nothing and exits 0. + +## Creating fresh work + + goga topics create Feature/Foo_Bar --from-current + goga topics create Feature/Foo_Bar --base-ref origin/main + goga topics create Feature/Foo_Bar -t "Payment retry" + goga topics create Feature/Foo_Bar -s + goga topics --year 2025 create Feature/Foo_Bar --base-ref origin/main + +Creates the branch off the base — --base-ref, topics.base_ref of +.goga/config.yml, or --from-current (the current HEAD); no base at all +is a clean error before anything else, naming the flag and the +configuration line. The preflight (an empty slug, an occupied branch +name or slug, the current branch hosting the same slug) runs before +the editor: creating the existing is an error with a hint to the +board — the todo of an existing topic is `goga topics switch ID +--todo`. An explicit --todo/-t value (only the value form exists; an +empty value counts as absent) is the todo; without a value a terminal +opens the external editor ($VISUAL/$EDITOR/vi) — an empty or unchanged +file cancels the entry; without a terminal and without a value the +command is a clean error naming --todo "...". By default the saved +text becomes one quarantined commit — todo.md on top of the base — and +the branch is planted at it: you stay on your branch, nothing lands +in the working copy, and the todo is required (a cancelled entry is a +clean error naming --todo and --switch). --switch/-s checks out the +fresh branch instead — the topic directory and todo.md appear in the +working copy uncommitted, and the todo is optional; --switch acts only +without --publish. On a terminal without --publish the +"Publish the branch to origin? [y/N]" ask appears only when a todo was obtained; +confirming publishes with a full rollback on failure, declining takes +the local path. + +## Creating and publishing fresh work + + goga topics create Feature/Foo_Bar --publish -t "Payment retry" --base-ref origin/main + goga topics create Feature/Foo_Bar -p -t "Payment retry" -c "chore: new topic {slug}" + +The publish path needs no terminal and asks nothing: the todo comes +from --todo/-t. The base comes from --base-ref, topics.base_ref, or +--from-current. --commit/-c (topics.publish_commit, default +`goga: create topic {slug}`) stays publication-only — an error without +--publish. A failed publication rolls back fully — the branch is +deleted and one clean error names the reason. + +## Switching to existing work + + goga topics switch history-com + goga topics switch history-com --todo + +Resolves the identifier — exact branch name, exact topic slug, then +prefixes — and switches. Several candidates offer a numbered list with +statuses; without interactive input the command fails with the list. +Already being on the host is an idempotent success. A dirty working +tree is a clean error when a mutation is needed. With --todo the +editor opens with the topic's todo.md after the switch: saving +overwrites the file without a commit, cancelling leaves it untouched. +--todo on a branch without a topic, or without a terminal, is a clean +error before the switch. Switching is always local. + +## Deleting topics + + goga topics delete feature-foo release-1-3-0 + goga topics delete feature-foo --yes + +Resolves every identifier (branch name, topic slug, prefix — plus +topic directories of the year no branch hosts); an unknown or +ambiguous identifier cancels the whole call before anything is +removed. One confirmation for the whole list — "Delete N topics? +[y/N]" with the topic-to-branch pairs; --yes/-y skips it (a +non-terminal without --yes is a clean error; the -y collision with the +group --year is resolved by position). The deletion is symmetric to +creation-and-publication: the local branch and its origin twin are +both removed (the local first; a failed remote deletion restores the +local branch and stops with one clean error), and the topic directory +joins the deletion of every target — branches or none. The current +branch hosting a target is a clean error — switch away first. Merged +work is out of scope: a topic hosted only by branches that are not its +own topic branch is a clean error naming the hosting branch; a topic +carried by both its own branch and a merged-work host deletes its +eligible refs but keeps its directory — the merged host's tree +survives. Unmerged commits never block: the deletion is unconditional +after the confirmation. + +## Exit codes + +Every subcommand exits 0 on success (an empty board included) and 1 on +error, with the error on stderr and no traceback. diff --git a/goga/commands/topics/CODEMANIFEST b/goga/commands/topics/CODEMANIFEST new file mode 100644 index 00000000..f6c3916d --- /dev/null +++ b/goga/commands/topics/CODEMANIFEST @@ -0,0 +1,316 @@ +Imports: + - Types: + - BoardRecord + - collect_topic_board + - switch_topic + - create_topic + - resolve_delete_targets + - delete_topics + Usages: + - topic-board + - switching + - todo-entry + - creating + - publishing + - deleting + From: goga/topics + - Types: + - load_project_config + - TopicsConfig + Usages: + - project-configuration + From: goga/config + +Usages: + convention: .goga/usages/conventions.md + click: .goga/usages/cooks/click.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + Use the `click` practice to build the topics command group: the group + decorator with its own option, the subcommand registration, the + flags, arguments, and options of each subcommand, echo, exit-code + propagation, the confirmation of the delete subcommand, and the + clean error rendering. + + Use the `creating` and `publishing` practices for the creation and + publication contracts of the domain, the `switching` practice for + the switching contract, the `todo-entry` practice for the todo entry + behind the --todo flags, the `topic-board` practice for the board + contract, and the `deleting` practice for the deletion contract of + the domain. Use the `project-configuration` practice for the schema + of the topics section. + + The creation inputs resolve their values at this layer: the base — + a flag beats the topics section — `TopicsConfig` — of the project + configuration read via `load_project_config`, which beats the + current HEAD requested explicitly; no base at all is a clean error + naming the flag and the configuration line. The commit message + template — a flag beats the topics section; the built-in default + lives in the domain. The switch flag passes through to the domain + like the todo value — no resolution happens here. The configuration + is read only for values no flag provided. + + This cell is the CLI surface of the topics domain: a thin wrapper + that resolves inputs, delegates every computation to the domain + routines, renders the board, and confirms the deletion. No inventory + walking, no switch resolution, no git access, no editor session live + here. Domain errors surface as clean CLI errors (stderr, non-zero + exit, no traceback). Use relative imports. + +--- + +"topics(year: str | None = None)": + location: topics.py + annotations: | + The goga topics command group — a click.Group container for the + topics subcommands, exported via __all__ and registered in the + root application group. The group carries the year scope every + subcommand shares. + + `year`: the --year/-y group option — exactly one year, four digits + the recognized form; None means the current year; a search + across years does not exist + + Use the `click` practice for the group decorator, the group option, + and the subcommand registration. + + Subcommand surfaces: + - board — a --remote/-r flag, an --info/-i flag + - create — a NAME positional, a --todo/-t option, a --publish/-p + flag, a --base-ref option, a --from-current flag, a --commit/-c + option, a --switch/-s flag + - switch — an IDENTIFIER positional, a --todo flag + - delete — IDENTIFIER positionals, a --yes/-y flag + + Apply the `convention` CLI command docstring rule for the --help + text (rendered verbatim by Click; omit Args/Returns/Raises). + methods: + "board(remote: bool = False, info: bool = False) -> exit_code: int": | + Subcommand goga topics board: print the board — the cross-branch + topic inventory of the scoped year as a three-column table, or a + four-column table with the todo column under --info/-i. + + `remote`: the --remote/-r flag — read remote-tracking refs instead of + local branches + `info`: the --info/-i flag — add the todo column to the table + `exit_code`: 0 on success (an empty board included), 1 on error + + Apply the `topic-board` practice for the board contract of the + domain. + Apply the `click` practice for the flag, echo, and the color rules. + + Algorithm: + 1. Collect the board via `collect_topic_board` with the scoped year + and `remote` + 2. Measure the terminal width + 3. Render the table via `render_topic_board` with the records, the + width, and `info` + 4. An empty board renders nothing — exit 0 + + Requirements: + - Read-only — nothing is created, written, or switched + + Constraints: + - Do not print the year or the artifacts, and no heading line + outside the table — the table carries topic, branch, the todo + column under `info`, and statuses only + "create(branch_name: str, todo: str | None = None, publish: bool = False, base_ref: str | None = None, from_current: bool = False, commit_message: str | None = None, switch: bool = False) -> exit_code: int": | + Subcommand goga topics create: create fresh work — a branch off + the resolved base with the name as entered, its topic of the + scoped year, and an optional multi-line todo; by default the + branch is planted at one commit carrying the topic's todo.md and + the caller stays on their branch; under --switch the branch is + checked out with its topic directory in the working copy; under + --publish the work is created off the base and published to + origin without switching. + + `branch_name`: NAME positional — the branch name as entered + `todo`: the --todo/-t value — the todo text; only the value form + exists, an empty value counts as an absent option + `publish`: the --publish/-p flag — the publication path without + the ask + `base_ref`: the --base-ref value — the base of the branch; valid + with and without `publish` + `from_current`: the --from-current flag — the current HEAD as + the base + `commit_message`: the --commit/-c value — the message template; + publication-only + `switch`: the --switch/-s flag — the checked-out creation; acts + only without `publish` + `exit_code`: 0 on success, 1 on error + + Apply the `creating` practice for the creation contract of the + domain. + Apply the `publishing` practice for the publication contract of + the domain. + Apply the `project-configuration` practice for the topics + section schema. + Apply the `click` practice for the options and exit-code + propagation. + + Algorithm: + 1. `commit_message` without `publish` -> clean error: the option + is publication-only + 2. `switch` together with `publish` -> clean error: the + publication never switches + 3. Resolve the base — `base_ref`, otherwise the topics section of + the configuration loaded via `load_project_config`, otherwise + the current HEAD under `from_current`; no base at all -> + clean error naming the flag and the configuration line, + before anything else + 4. Resolve the template — `commit_message`, otherwise the topics + section, otherwise None (the built-in default lives in the + domain) + 5. Delegate to `create_topic` with the name, the base, the todo, + `publish`, the template, the scoped year, and `switch` + 6. Echo the single result line + 7. Propagate the exit code + + Requirements: + - The configuration is read only for values no flag provided; a + missing configuration file counts as an unset value; a present + but invalid one surfaces its own clean error + + Constraints: + - Do not validate the name at the CLI layer — the domain and git + own that + - Do not open the editor here — the entry belongs to the domain + - Do not switch branches or render the board here — every + computation belongs to the domain + "switch(identifier: str, todo: bool = False) -> exit_code: int": | + Subcommand goga topics switch: bring the repository onto the + branch hosting the requested work; under --todo enter the todo + of the switched topic after the switch. + + `identifier`: IDENTIFIER positional — a branch name, a topic + slug, or their prefix + `todo`: the --todo flag — enter the todo of the switched topic + `exit_code`: 0 on success, 1 on error + + Apply the `switching` practice for the switching contract of the + domain. + Apply the `click` practice for the flag and exit-code + propagation. + + Algorithm: + 1. Delegate to `switch_topic` with the identifier, the flag, and + the scoped year + 2. Echo the single result line + 3. Propagate the exit code + + Constraints: + - Do not launch any pipeline — continuation is a separate + command + "delete(identifiers: tuple[str, ...], yes: bool = False) -> exit_code: int": | + Subcommand goga topics delete: resolve and delete identified + topics — the local branch, the origin twin, and the topic + directory — under one confirmation for the whole list. + + `identifiers`: IDENTIFIER positionals — branch names, topic + slugs, or their prefixes + `yes`: the --yes/-y flag — skip the confirmation + `exit_code`: 0 on success (a declined confirmation included), 1 + on error + + Apply the `deleting` practice for the deletion contract of the + domain. + Apply the `click` practice for the confirmation, echo, and + exit-code propagation. + + Algorithm: + 1. Resolve the targets via `resolve_delete_targets` with the + identifiers and the scoped year — a resolution error is clean + and deletes nothing + 2. Without `yes`: no interactive terminal -> clean error; + otherwise print the topic-to-branch pairs of the list and ask + one confirmation for the whole list; a declined answer exits + 0 with nothing deleted + 3. Delegate to `delete_topics` with the targets and the scoped + year + 4. Echo the single result line + 5. Propagate the exit code + + Requirements: + - One confirmation for the whole list — never per topic + - The -y short form collides with the group --year; the + positions on the command line distinguish them + + Constraints: + - Do not offer an interactive choice among ambiguous candidates — + ambiguity is a domain error + - Do not delete anything before the resolution and the + confirmation are complete + +"render_topic_board(records: list[BoardRecord], width: int, info: bool = False)": + location: render.py + annotations: | + Render the board as a table: topic, branch, and statuses — under + `info` the todo column sits between branch and statuses. + + `records`: the collected board records — already sorted by the domain + `width`: the measured terminal width in columns + `info`: True adds the todo column and switches to the four-column + width rule + + Apply the `click` practice for echo. + + Algorithm: + 1. Compute the column widths from `width` alone per the width rule of + the requirements — the three-column rule without `info`, the + four-column rule with it; the grid is fixed and independent of the + record content + 2. Print one header row and one separator row with column and row + dividers — the column order is topic, branch, todo, statuses + under `info` + 3. Print each record: every text column truncated with an ellipsis when + it exceeds its column, the statuses wrapped onto continuation lines + without affecting the column widths + 4. Print one row divider after every record — the same dash run as the + header separator — the last record included; the wrapped + continuation lines of one record stay undivided + 5. Mark the record hosting the current branch with an asterisk; keep + the remote prefix of a remote host visible in the branch column + 6. An empty `records` prints nothing + + Requirements: + - Three-column widths: topic and branch get an equal share first, + statuses take the remainder — each of topic and branch is capped at + one third of `width` minus the dividers, statuses receives what is + left, and every column keeps a minimum of 8 columns before + truncation applies + - Four-column widths under `info`: topic, branch, and todo get an + equal share — each capped at one quarter of `width` minus the + dividers, statuses receives the non-negative remainder, and every + column keeps a minimum of 8 columns before truncation applies + - The todo column header is the word todo + - A todo of None or an empty string renders an empty cell + - The truncation marker is a single ellipsis character + - An overlong status is truncated like the other columns + - A row divider — identical to the header separator row — closes every + record: it prints after the wrapped statuses of each record, the + last record included, and never between the continuation lines of + one record + - The table never exceeds `width`, with one documented exception: when + the minimum columns no longer fit — below the narrow threshold of the + active column rule — every column keeps its minimum of 8 and the + table may exceed `width`; minimum readability wins over the width cap + on ultra-narrow terminals + + Constraints: + - Read-only on `records` — do not mutate, do not re-sort, do not filter + - Do not print the year or the artifacts + +--- + +Author: Goga +CreatedAt: 29/08/26 +Description: | + The goga topics command group — board, create, switch, and delete — + over the topics domain. diff --git a/goga/commands/topics/__init__.py b/goga/commands/topics/__init__.py new file mode 100644 index 00000000..898c9fef --- /dev/null +++ b/goga/commands/topics/__init__.py @@ -0,0 +1,13 @@ +"""Topics command cell — the CLI surface of the topics domain. + +A thin wrapper: the ``topics`` group resolves the inputs and delegates +every computation to the domain routines of ``goga.topics``, and the board +renderer shapes the collected records into the three-column table. No +inventory walking, no switch resolution, and no git access live here; +domain errors surface as clean CLI errors. +""" + +from .render import render_topic_board +from .topics import topics + +__all__: list[str] = ["render_topic_board", "topics"] diff --git a/goga/commands/topics/render.py b/goga/commands/topics/render.py new file mode 100644 index 00000000..020e7cb5 --- /dev/null +++ b/goga/commands/topics/render.py @@ -0,0 +1,219 @@ +"""Console rendering for the topics command group. + +The entity declared in the cell CODEMANIFEST with ``location: render.py``: +the board renderer — the collected board records as a three-column table of +topic, branch, and statuses, or as a four-column table with the todo +column between branch and statuses under ``info``. Pure output: the records +print as given, never sorted, filtered, or recomputed; the domain owns the +collection and the ordering. +""" + +from __future__ import annotations + +import click + +from ...topics import BoardRecord + +# The fixed grid overhead per column — one pipe and two padding spaces; the +# leading pipe replaces the pipe of the first column, so a table of k text +# columns carries 3*k overhead columns in total. +# The minimum of every column before truncation applies. +_MIN_COLUMN = 8 +# The current-row marker — a prefix inside the topic cell. +_CURRENT_MARKER = "* " +# The truncation marker — a single ellipsis character. +_ELLIPSIS = "…" + + +def render_topic_board(records: list[BoardRecord], width: int, info: bool = False) -> None: + """Render the board as a table: topic, branch, and statuses. + + Under ``info``, the todo column sits between branch and statuses. + + Args: + records: The collected board records — already sorted by the domain. + width: The measured terminal width in columns. + info: ``True`` adds the todo column and switches to the + four-column width rule. + + Algorithm: + 1. Compute the column widths from ``width`` alone per the width + rule of the requirements — the three-column rule without + ``info``, the four-column rule with it; the grid is fixed and + independent of the record content + 2. Print one header row and one separator row with column and row + dividers — the column order is topic, branch, todo, statuses + under ``info`` + 3. Print each record: every text column truncated with an ellipsis + when it exceeds its column, and the statuses wrapped onto + continuation lines without affecting the column widths + 4. Print one row divider after every record — the same dash run as + the header separator — the last record included; the wrapped + continuation lines of one record stay undivided + 5. Mark the record hosting the current branch with an asterisk; the + remote prefix of a remote host stays visible in the branch column + 6. An empty ``records`` prints nothing + + Requirements: + The three-column rule gives topic and branch an equal share first — + each capped at one third of ``width`` minus the dividers — and + statuses the remainder; the four-column rule under ``info`` gives + topic, branch, and todo an equal share — each capped at one quarter + of ``width`` minus the dividers — and statuses the non-negative + remainder. Every column keeps a minimum of 8 columns before + truncation applies. The todo column header is the word todo. A todo + of ``None`` or an empty string renders an empty cell. The truncation + marker is a single ellipsis character; + an overlong status segment is truncated like the other columns. A + row divider — identical to the header separator row — closes every + record: it prints after the wrapped statuses of each record, the + last record included, and never between the continuation lines of + one record. The + table never exceeds ``width``, with one documented exception: below + the narrow threshold of the active column rule — 33 columns for the + thirds, 44 for the quarters — every column keeps its minimum of 8 + and the table may exceed ``width``; minimum readability wins over + the width cap on ultra-narrow terminals. + + Constraints: + Read-only on ``records`` — do not mutate, do not re-sort, do not + filter. Do not print the year or the artifacts. + """ + if not records: + return + + columns_count = 4 if info else 3 + caps = _column_widths(width, columns_count) + header = ("Topic", "Branch", "todo", "Statuses") if info else ("Topic", "Branch", "Statuses") + + click.echo(_row_line(header, caps)) + click.echo(_separator(caps)) + + for record in records: + topic_text = f"{_CURRENT_MARKER}{record.topic}" if record.current else record.topic + leading = (topic_text, record.branch, record.todo or "") if info else (topic_text, record.branch) + segments = [f"[{status}]" for status in record.statuses] + + for index, statuses_line in enumerate(_wrap_segments(segments, caps[-1])): + cells = (*(cell if index == 0 else "" for cell in leading), statuses_line) + click.echo(_row_line(cells, caps)) + + click.echo(_separator(caps)) + + +def _column_widths(width: int, columns_count: int) -> tuple[int, ...]: + """Resolve the column widths of the grid for one terminal width. + + Args: + width: The measured terminal width in columns. + columns_count: The number of text columns of the grid — 3 or 4. + + Returns: + The caps of every column in grid order. The text columns take an + equal share of the usable width and statuses the non-negative + remainder; when the minimums no longer fit, every column keeps its + minimum of 8 and the table may exceed ``width``. + """ + usable = width - 3 * columns_count + + if usable < columns_count * _MIN_COLUMN: + return (_MIN_COLUMN,) * columns_count + + cap = usable // columns_count + + return (cap,) * (columns_count - 1) + (usable - (columns_count - 1) * cap,) + + +def _row_line(cells: tuple[str, ...], caps: tuple[int, ...]) -> str: + """Build one grid row — every cell fitted to its column. + + The fixed overhead of the grid is one pipe and two padding spaces per + column: the leading pipe, the column separators, and the right padding + of the last cell — the table closes on the padded column, not on a + trailing pipe. + + Args: + cells: The cell texts of this grid line in grid order — the + continuation lines pass the text columns empty. + caps: The caps of every column. + + Returns: + The grid line with the cells truncated, padded, and divided. + """ + return f"| {' | '.join(_fit(text, cap) for text, cap in zip(cells, caps, strict=True))} " + + +def _separator(caps: tuple[int, ...]) -> str: + """Build the row divider of the grid. + + Args: + caps: The caps of every column. + + Returns: + The separator row — one dash run per column under its padding, + joined by the pipes of the grid. + """ + return "|" + "|".join("-" * (cap + 2) for cap in caps) + + +def _fit(text: str, cap: int) -> str: + """Fit one cell — truncate an overlong text, then pad to the column. + + Args: + text: The cell text — already carrying the current-row marker when + the row hosts the current branch. + cap: The column cap in columns. + + Returns: + The cell text of exactly ``cap`` columns. + """ + return _truncate(text, cap).ljust(cap) + + +def _truncate(text: str, cap: int) -> str: + """Truncate one text to its column — the single-character ellipsis marker. + + Args: + text: The text to fit. + cap: The column cap in columns. + + Returns: + The text unchanged when it fits; ``text[: cap - 1]`` plus the + ellipsis when it exceeds the cap. + """ + if len(text) > cap: + return f"{text[: cap - 1]}{_ELLIPSIS}" + + return text + + +def _wrap_segments(segments: list[str], statuses_w: int) -> list[str]: + """Wrap the status segments onto the continuation lines of the column. + + Args: + segments: The bracketed status names of one record. + statuses_w: The cap of the statuses column. + + Returns: + The statuses cell content per grid line — a greedy fill that keeps + every segment whole; a single segment longer than the column is + truncated with the ellipsis like the other columns. The grid lives + on: the continuation lines carry empty text cells. + """ + lines: list[str] = [] + current = "" + + for segment in segments: + piece = _truncate(segment, statuses_w) + if not current: + current = piece + elif len(current) + 1 + len(piece) <= statuses_w: + current = f"{current} {piece}" + else: + lines.append(current) + current = piece + + if current or not lines: + lines.append(current) + + return lines diff --git a/goga/commands/topics/topics.py b/goga/commands/topics/topics.py new file mode 100644 index 00000000..9b103905 --- /dev/null +++ b/goga/commands/topics/topics.py @@ -0,0 +1,287 @@ +"""The ``goga topics`` command group — the CLI surface of the topics domain. + +The click group declared in the cell CODEMANIFEST with ``location: +topics.py``: the ``board``/``create``/``switch``/``delete`` subcommands +over the topics domain. The group carries the year scope every subcommand +shares and is a thin wrapper — it resolves the inputs, delegates every +computation to the domain routines of ``goga.topics``, and renders the +board through the ``render`` module. The creation inputs resolve their +values at this layer: the base — ``--base-ref``, the ``topics`` section +of the project configuration, the current HEAD under ``--from-current`` +— and the commit message template — ``--commit/-c``, the ``topics`` +section, the built-in default of the domain; the configuration is read +lazily, only for values no flag provided. The deletion is confirmed at +this layer — one confirmation for the whole resolved list. No inventory +walking, no switch resolution, no git access, and no editor session +live here — the todo value and the ``--switch/-s`` flag pass through and +the entry belongs to the domain. Domain errors surface as clean CLI +errors. +""" + +from __future__ import annotations + +import shutil +import sys +from dataclasses import dataclass + +import click +import yaml + +from ...config import TopicsConfig, load_project_config +from ...topics import ( + collect_topic_board, + create_topic, + delete_topics, + resolve_delete_targets, + switch_topic, +) +from .render import render_topic_board + + +@dataclass(kw_only=True) +class _TopicsScope: + """The year scope shared by every subcommand of the group.""" + + year: str | None = None + + +def _topics_section() -> TopicsConfig | None: + """Read the topics section of .goga/config.yml — None when unset or unconfigured.""" + try: + return load_project_config().topics + except FileNotFoundError: + return None + except OSError as exc: + # A present-but-unreadable file (a directory in its place, a + # permission failure) surfaces as its own clean error — the loader + # documents OSError on its Raises surface. FileNotFoundError, an + # OSError subclass, is already handled above as "unset". + raise click.ClickException(str(exc)) from exc + except (KeyError, ValueError, yaml.YAMLError) as exc: + raise click.ClickException(str(exc)) from exc + + +@click.group() +@click.option( + "--year", + "-y", + default=None, + help="Four-digit year scope shared by every subcommand (default: the current year)", +) +@click.pass_context +def topics(ctx: click.Context, year: str | None = None) -> None: + """Work with the topics of one year.""" + ctx.ensure_object(_TopicsScope) + ctx.obj.year = year + + +@topics.command("board") +@click.option( + "--remote", + "-r", + is_flag=True, + default=False, + help="Read remote-tracking refs instead of local branches.", +) +@click.option( + "--info", + "-i", + is_flag=True, + default=False, + help="Add the todo column to the table.", +) +@click.pass_obj +def board(scope: _TopicsScope, remote: bool = False, info: bool = False) -> None: + """Print the board — the cross-branch topic inventory of the scoped year. + + One three-column table row per topic: topic, branch, statuses — the row + of the current branch carries an asterisk and the statuses wrap onto + continuation lines when they overflow. --info/-i adds the todo column + — the todo summary of the topic — between branch and statuses. + --remote/-r reads remote-tracking refs instead of local branches. An + empty board prints nothing and exits 0 — it is not an error. The year + defaults to the current one and is never printed. + """ + records = collect_topic_board(scope.year, remote) + render_topic_board(records, shutil.get_terminal_size().columns, info) + click.get_current_context().exit(0) + + +@topics.command("create") +@click.argument("branch_name") +@click.option( + "--todo", + "-t", + "todo", + default=None, + metavar="[TEXT]", + help="Todo of the fresh work; an empty value counts as absent; with no todo given a terminal opens the editor.", +) +@click.option( + "--publish", + "-p", + is_flag=True, + default=False, + help="Create the work off the base and publish it to origin without switching and without the ask.", +) +@click.option( + "--base-ref", + default=None, + help="Base of the branch; beats topics.base_ref of .goga/config.yml, which beats --from-current.", +) +@click.option( + "--from-current", + is_flag=True, + default=False, + help="Base the branch on the current HEAD.", +) +@click.option( + "--commit", + "-c", + "commit_message", + default=None, + help="Commit message template, publication-only; beats topics.publish_commit — {slug} takes the topic slug.", +) +@click.option( + "--switch", + "-s", + is_flag=True, + default=False, + help="Switch to the created branch after the creation; without the flag you stay on your branch.", +) +@click.pass_obj +def create( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared CLI surface + scope: _TopicsScope, + branch_name: str, + todo: str | None = None, + publish: bool = False, + base_ref: str | None = None, + from_current: bool = False, + commit_message: str | None = None, + switch: bool = False, +) -> None: + """Create fresh work — a branch off the resolved base with its topic. + + The branch name is taken verbatim; the topic of the scoped year takes + its slug. The base resolves as --base-ref, then topics.base_ref of + .goga/config.yml, then the current HEAD under --from-current; no base + at all is a clean error naming the flag and the configuration line. + By default the branch is planted at one commit carrying the topic's + todo.md and you stay on your branch — the todo is required on this + path. An explicit --todo/-t value is the todo — the value form only, + a value-less --todo is click's own usage error; an empty value counts + as absent; with no todo given a terminal opens the external editor + and without a terminal the command is a clean error naming the + option. --switch/-s checks out the fresh branch instead — the topic + directory and todo.md land in the working copy uncommitted and the + todo is optional. On a terminal without --publish the publication ask + appears once a todo is resolved; declining takes the local path. + --publish/-p publishes to origin without switching and without the + ask; a failed publication rolls back fully. --commit/-c — the + message template; topics.publish_commit; the built-in default lives + in the domain — is publication-only. One result line on stdout. + """ + if commit_message is not None and not publish: + raise click.ClickException("--commit is publication-only — it acts only together with --publish") + + if switch and publish: + raise click.ClickException("--switch acts only without --publish — the publication never switches") + + # The empty --todo value counts as an absent option; the entry and + # the write belong to the domain. + if todo == "": + todo = None + + # The configuration is read lazily — only when a value no flag + # provided has to come from it; both values given means zero reads. + section = _topics_section() if base_ref is None or commit_message is None else None + + base = base_ref + if base is None and section is not None: + base = section.base_ref + if base is None and from_current: + base = "HEAD" + if base is None: + raise click.ClickException( + "no base for the branch — pass --base-ref or --from-current, or set " + "topics.base_ref in .goga/config.yml:\ntopics:\n base_ref: origin/main" + ) + + template = commit_message + if template is None and section is not None: + template = section.publish_commit + + line = create_topic(branch_name, base, todo, publish, template, scope.year, switch) + click.echo(line) + click.get_current_context().exit(0) + + +@topics.command("switch") +@click.argument("identifier") +@click.option( + "--todo", + is_flag=True, + default=False, + help="Open the editor with the switched topic's todo.md after the switch.", +) +@click.pass_obj +def switch(scope: _TopicsScope, identifier: str, todo: bool = False) -> None: + """Bring the repository onto the branch hosting the requested work. + + IDENTIFIER is a branch name, a topic slug, or their prefix — resolved in + that order. Several candidates offer a numbered list and a prompt on an + interactive terminal; already being on the host is an idempotent + success, and a dirty working tree is a clean error when a mutation is + needed. With --todo the external editor opens with the switched + topic's todo.md after the switch — saving overwrites the file without + a commit, cancelling leaves it untouched; the flag needs an + interactive terminal and a topic on the host branch. One result line + on stdout; no pipeline is launched — continuation is a separate + command. + """ + line = switch_topic(identifier, todo, scope.year) + click.echo(line) + click.get_current_context().exit(0) + + +@topics.command("delete") +@click.argument("identifiers", nargs=-1, required=True) +@click.option( + "--yes", + "-y", + is_flag=True, + default=False, + help="Skip the confirmation; sits after the subcommand token, unlike the group -y year.", +) +@click.pass_obj +def delete(scope: _TopicsScope, identifiers: tuple[str, ...], yes: bool = False) -> None: + """Delete identified topics — the branch, its origin twin, and the directory. + + Every IDENTIFIER resolves first — a branch name, a topic slug, or + their prefix; an unknown or ambiguous identifier is a clean error and + nothing is deleted. The resolved list prints one line per target — + the topic, then its branch, its remote twin, or (directory only) — + and one confirmation covers the whole list; a declined answer exits + 0 with nothing deleted. --yes/-y skips the confirmation; without it a + non-interactive terminal is a clean error. The deletion removes each + topic's local branch, its origin twin, and its topic directory; the + current branch hosting a target is a clean error — switch away + first. One result line on stdout. + """ + targets = resolve_delete_targets(list(identifiers), scope.year) + + if not yes: + if not sys.stdin.isatty(): + raise click.ClickException( + "the deletion confirmation needs an interactive terminal — pass --yes/-y to skip it" + ) + + for target in targets: + click.echo(f"{target.topic} -> {target.branch or target.remote or '(directory only)'}") + + if not click.confirm(f"Delete {len(targets)} topic(s)?"): + click.get_current_context().exit(0) + + line = delete_topics(targets, scope.year) + click.echo(line) + click.get_current_context().exit(0) diff --git a/goga/config/.usages/project-configuration.md b/goga/config/.usages/project-configuration.md index 2c9ee194..83c6039c 100644 --- a/goga/config/.usages/project-configuration.md +++ b/goga/config/.usages/project-configuration.md @@ -18,6 +18,7 @@ from goga.config import ( CodemanifestConfig, DepConfig, LintConfig, + TopicsConfig, load_project_config, ) ``` @@ -46,10 +47,16 @@ config = load_project_config() - A present-but-non-mapping `pipeline` or `build` value (e.g. `pipeline: 5`, `pipeline:` null, `build: true`) raises `ValueError`, not `AttributeError` - Raises `yaml.YAMLError` on invalid YAML syntax - Optional `build.review_executor` follows structural-only validation: field - types, a list-of-strings check for `roles`, and a strings-mapping check for - `env`; an empty `roles` list and an empty `env` mapping pass through verbatim - — the empty-to-full-set (roles) and env-requires-agent (env) semantics belong - to the consuming command + types, a list-of-strings check for `roles`, a strings-mapping check for + `env`, and scalar type checks for `base_ref` (string) and `patience` + (integer); an empty `roles` list and an empty `env` mapping pass through + verbatim — the empty-to-full-set (roles) and env-requires-agent (env) + semantics belong to the consuming command +- Optional `topics` follows structural-only validation: `topics.base_ref` and + `topics.publish_commit` are strings when present — absent/YAML-null/empty/ + whitespace resolves to `None`; a present-but-non-mapping `topics` raises + `ValueError`. Rev resolvability, template grammar, and the default template + belong to the consuming command **Error handling**: @@ -131,7 +138,6 @@ build: idle_timeout: "1h" wait: "5m" max_iterations: 10 - review_patience: 3 prompts_dir: /custom/prompts agents_dir: /custom/agents codex_review: true @@ -143,6 +149,8 @@ build: - testing env: # mapping | absent — review-pass env layer ANTHROPIC_MODEL: reviewer-model + base_ref: origin/1.2.x # str | absent — review diff base (branch or hash) + patience: 3 # int | absent — stop external review after N unchanged rounds codemanifest: usages: usage_name: path/to/file.md @@ -162,6 +170,9 @@ lint: # optional linter section ignore: # list of exact relative paths to exclude - .venv/ # glob (**, *, ?) is NOT supported - build/dist +topics: # optional fast-creation section + base_ref: origin/main # str | absent — base of published topic branches + publish_commit: "goga: create topic {slug}" # str | absent — commit message template ``` ### Required Fields @@ -214,7 +225,6 @@ afm) that consume these fields. | `build.idle_timeout` | str | None | Idle timeout (Go duration format) | | `build.wait` | str | None | Rate-limit retry wait (Go duration format) | | `build.max_iterations` | int | None | Maximum task iteration count | -| `build.review_patience` | int | None | Review convergence threshold | | `build.prompts_dir` | str | None | Custom prompt directory path | | `build.agents_dir` | str | None | Custom agent directory path | | `build.codex_review` | bool | None | Enable external codex review (mapped to ralphex `codex_enabled`) | @@ -223,6 +233,8 @@ afm) that consume these fields. | `build.review_executor.agent` | str | None | Review executor name (resolved by the consumer) | | `build.review_executor.roles` | list | None | Reviewer composition; empty list passes verbatim (full default set is consumer semantics) | | `build.review_executor.env` | mapping | `{}` | Review-pass env layer ({str: str}); empty when absent/YAML-null/`{}`; requires `agent` when non-empty (enforced by the consumer) | +| `build.review_executor.base_ref` | str | None | Review diff base — branch name or commit hash; overrides ralphex's default-branch detection for review diffs. Verbatim, no validation at the config layer | +| `build.review_executor.patience` | int | None | Stop the external review after N consecutive unchanged rounds | | `codemanifest` | mapping | None | CODEMANIFEST usage and annotation config | | `codemanifest.usages` | mapping | `{}` | Usage name-to-path mapping (`{str: str}`) | | `codemanifest.annotations` | str | None | Freeform annotations for the AI agent | @@ -232,6 +244,9 @@ afm) that consume these fields. | `usages.<group>.<dep>.ref` | str | None | optional git ref (branch/tag/commit; absent → default branch) | | `lint` | mapping | None | Linter section (optional); when absent, config.lint is None | | `lint.ignore` | list | `[]` | List of exact relative paths excluded from AST traversal by `goga lint`. Glob is not supported | +| `topics` | mapping | None | Fast-creation section (structural validation only) | +| `topics.base_ref` | str | None | Base revision of published topic branches, verbatim | +| `topics.publish_commit` | str | None | Commit message template; the {slug} placeholder is optional, verbatim | ## Accessing Configuration Data @@ -284,13 +299,29 @@ config.build.review_executor.skip # bool | None — tri-state skip source config.build.review_executor.agent # str | None — review executor name config.build.review_executor.roles # list[str] | None — verbatim; [] means the full default set to the consumer config.build.review_executor.env # dict — {str: str}, empty when absent +config.build.review_executor.base_ref # str | None — review diff base, verbatim +config.build.review_executor.patience # int | None — external-review stop threshold # CodemanifestConfig fields — None when the `codemanifest` section is absent config.codemanifest # CodemanifestConfig | None config.codemanifest.usages # dict — {str: str} config.codemanifest.annotations # str | None + +# TopicsConfig fields — None when the `topics` section is absent +config.topics # TopicsConfig | None +config.topics.base_ref # str | None — base revision, verbatim +config.topics.publish_commit # str | None — commit message template, verbatim ``` +```yaml +topics: + base_ref: origin/main + publish_commit: "goga: create topic {slug}" +``` + +The default template and the `{slug}` substitution belong to the consuming +command (the create command). + ### `tools` accessor — no-validation contract `config.tools` exposes the raw mapping from `.goga/config.yml`. The loader diff --git a/goga/config/CODEMANIFEST b/goga/config/CODEMANIFEST index 7b1e25d3..2b0d32d9 100644 --- a/goga/config/CODEMANIFEST +++ b/goga/config/CODEMANIFEST @@ -9,6 +9,7 @@ Imports: - CodemanifestConfig - DepConfig - LintConfig + - TopicsConfig From: goga/config/project - Types: - HomeConfig @@ -30,11 +31,12 @@ Annotations: | - Organizing the test infrastructure - Understanding the general principles and rules of development and testing in the project - This cell is a re-export facade: it embeds (re-exports) all configuration types - from goga/config/project (project configuration), goga/config/home - (home/docker configuration), and goga/config/git (git-environment - introspection — `resolve_project_name`) so consumers import a single entry - point (From: goga/config). It owns no behavior — all logic lives in the child + This cell is a re-export facade: it embeds (re-exports) all configuration + types from goga/config/project (project configuration, including the + topics fast-creation section), goga/config/home (home/docker + configuration), and goga/config/git (git-environment introspection — + `resolve_project_name`) so consumers import a single entry point + (From: goga/config). It owns no behavior — all logic lives in the child cells. --- @@ -52,6 +54,7 @@ Annotations: | ->DockerArgsConfig: {} ->load_home_config: {} ->resolve_project_name: {} +->TopicsConfig: {} --- @@ -59,6 +62,5 @@ Author: Goga CreatedAt: 24/07/26 Description: | Re-export facade — embeds project (goga/config/project), home - (goga/config/home), and git-introspection (goga/config/git — - `resolve_project_name`) configuration types so consumers import a single - entry point. Owns no behavior. + (goga/config/home), and git-introspection (goga/config/git) configuration + types so consumers import a single entry point. Owns no behavior. diff --git a/goga/config/__init__.py b/goga/config/__init__.py index 0b95e388..977a3291 100644 --- a/goga/config/__init__.py +++ b/goga/config/__init__.py @@ -10,6 +10,7 @@ ProjectConfig, ReviewExecutorConfig, TaskExecutorConfig, + TopicsConfig, ) from .project.loader import load_project_config @@ -24,6 +25,7 @@ "ProjectConfig", "ReviewExecutorConfig", "TaskExecutorConfig", + "TopicsConfig", "load_home_config", "load_project_config", "resolve_project_name", diff --git a/goga/config/project/CODEMANIFEST b/goga/config/project/CODEMANIFEST index 4a201628..c892f24f 100644 --- a/goga/config/project/CODEMANIFEST +++ b/goga/config/project/CODEMANIFEST @@ -32,10 +32,17 @@ Annotations: | to the owning consumer. The optional build.review_executor sub-section follows the same structural-only - stance: field types, a list-of-strings check for roles, and a strings-mapping - check for env; an empty roles list and an empty env mapping pass through - verbatim (the empty-to-full-set and the env-requires-agent meanings belong to - the consuming cell). + stance: field types, a list-of-strings check for roles, a strings-mapping + check for env, and scalar type checks for base_ref (string) and patience + (integer); an empty roles list and an empty env mapping pass through + verbatim (the empty-to-full-set and the env-requires-agent meanings belong + to the consuming cell). + + The optional topics section follows the same structural-only stance: the + loader enforces that topics.base_ref and topics.publish_commit are strings + when present — absent/YAML-null/empty/whitespace normalizes to None; rev + resolvability, template grammar, and the default template belong to the + consuming command. --- @@ -81,8 +88,12 @@ Annotations: | mapping → ValueError; a non-string key or value → ValueError (messages follow the build.task_executor.env pattern; YAML-null is a valid empty mapping — null-tolerance mirrors agent and roles of this step). - Construct a `ReviewExecutorConfig` from the resolved fields and pass it - into `BuildConfig`. + base_ref: absent/YAML-null/empty/whitespace → None; a non-string value → + ValueError (the agent pattern). + patience: absent/YAML-null → None; a non-int value (including a YAML + boolean) → ValueError — a structural type check. + Construct a `ReviewExecutorConfig` from the resolved fields (including + base_ref and patience) and pass it into `BuildConfig`. 8. Extract the optional codemanifest block; when present, construct a `CodemanifestConfig` from its usages and annotations fields, otherwise None 9. Extract the optional lint block. When the lint key is absent or @@ -92,8 +103,13 @@ Annotations: | element is a string (otherwise ValueError). Perform NO semantic validation of path contents (glob, existence, normalization) — only the structural "list of strings" check. Construct a `LintConfig` from the resolved ignore list. - 10. Extract the optional commands mapping (defaults to empty) - 11. Extract the optional tools mapping via the `yaml` practice. When the key + 10. Extract the optional topics block. When the topics key is absent or + YAML-null → topics=None. Present but not a mapping → ValueError. + When a mapping → extract the optional base_ref and publish_commit: + absent/YAML-null/empty/whitespace → None; a non-string value → + ValueError. Construct a `TopicsConfig` from the resolved fields. + 11. Extract the optional commands mapping (defaults to empty) + 12. Extract the optional tools mapping via the `yaml` practice. When the key is absent or YAML-null → set tools to None. When the key is present but the value is not a mapping → raise ValueError. When the value is a mapping → validate structurally that every key is a string and every @@ -104,7 +120,7 @@ Annotations: | non-grammar strings pass through the loader verbatim. The loader is NOT the validation authority for the version grammar — that responsibility belongs to the consumer. - 12. Extract the optional usages block. When the usages key is absent → set + 13. Extract the optional usages block. When the usages key is absent → set usages to None. When present but not a mapping → raise ValueError. When a mapping → for each group (str key → mapping) and each dep (str key → mapping): - require dep.git (non-empty str) — KeyError when missing, ValueError when invalid @@ -119,8 +135,8 @@ Annotations: | .goga/usages/<group>/<dep>/ segments in the downstream usages-sync consumer - construct a `DepConfig`(git, ref, root) per dep, preserving group/dep as dict keys Build usages as dict[str, dict[str, DepConfig]]; empty mapping when present-but-empty - 13. Construct and return a `ProjectConfig` from all assembled parts, including - dockerfile, tools, usages, and lint + 14. Construct and return a `ProjectConfig` from all assembled parts, including + dockerfile, tools, usages, lint, and topics Requirements: - Top-level image is the Docker image (None is valid — consumers raise @@ -141,6 +157,9 @@ Annotations: | - build.hosts is optional, defaults to an empty mapping - review_executor.env is stored verbatim — semantic validation (env requires agent) belongs to the consumer + - review_executor.base_ref and review_executor.patience are parsed + structural-only and stored verbatim — branch resolvability and value + semantics belong to the consumer - codemanifest is optional - tools is optional; values are stored verbatim with NO semantic validation — invalid forms (operator-prefixed, malformed numerics) pass through @@ -164,6 +183,11 @@ Annotations: | empty list; present-but-non-list or a non-string element → ValueError); no semantic validation of ignore contents at the loader level - Values are exposed as-is without default merge — consumers apply their own defaults + - topics is optional (None when absent/YAML-null; present-but-non-mapping + → ValueError); topics.base_ref and topics.publish_commit are optional + (absent/YAML-null/empty/whitespace → None; non-string → ValueError); + stored verbatim — rev resolvability, template grammar, and the + default template belong to the consumer Constraints: - Do not default image — None is a valid value, surface it to the caller @@ -190,9 +214,15 @@ Annotations: | at the loader level — semantics belong to the consumer - Do NOT validate review_executor.env semantics at the loader level — semantics belong to the consumer + - Do NOT validate topics.base_ref rev resolvability or + topics.publish_commit template semantics at the loader level — + structural typing only; the consumer applies the default template + - The final `ProjectConfig` assembly MUST include topics + - The review patience field is read only from + build.review_executor.patience - The final `ProjectConfig` assembly MUST include lint -"ProjectConfig(lang: str, image: str | None, dockerfile: str | None, build: BuildConfig | None, pipeline: PipelineConfig | None, commands: dict, codemanifest: CodemanifestConfig | None, tools: dict[str, str] | None, usages: dict[str, dict[str, DepConfig]] | None = None, lint: LintConfig | None = None)": +"ProjectConfig(lang: str, image: str | None, dockerfile: str | None, build: BuildConfig | None, pipeline: PipelineConfig | None, commands: dict, codemanifest: CodemanifestConfig | None, tools: dict[str, str] | None, usages: dict[str, dict[str, DepConfig]] | None = None, lint: LintConfig | None = None, topics: TopicsConfig | None = None)": location: config.py annotations: | Root project configuration object. Constructed by load_project_config. @@ -219,6 +249,9 @@ Annotations: | `lint`: optional lint configuration; instance of `LintConfig` or None when the lint section is absent; defaults to None (kw_only); callers may omit lint= + `topics`: optional fast-creation configuration; instance of + `TopicsConfig` or None when the topics section is absent; + defaults to None (kw_only), callers may omit topics= properties: "lang -> str": | Project language. Sourced from the root language directive in .goga/config.yml. @@ -270,8 +303,12 @@ Annotations: | Optional lint configuration from .goga/config.yml. Instance of `LintConfig`, or None when the lint section is absent. Defaults to None (kw_only) so ProjectConfig(...) callers may omit lint=. + "topics -> TopicsConfig | None": | + Fast-creation configuration from .goga/config.yml. Instance of + `TopicsConfig`, or None when the topics section is absent. Defaults to + None (kw_only) so ProjectConfig(...) callers may omit topics=. -"BuildConfig(task_executor: TaskExecutorConfig, worktree: bool | None, skip_finalize: bool | None, session_timeout: str | None, idle_timeout: str | None, wait: str | None, max_iterations: int | None, review_patience: int | None, prompts_dir: str | None, agents_dir: str | None, codex_review: bool | None, review_executor: ReviewExecutorConfig | None, proxy: str | None, hosts: dict[str, str])": +"BuildConfig(task_executor: TaskExecutorConfig, worktree: bool | None, skip_finalize: bool | None, session_timeout: str | None, idle_timeout: str | None, wait: str | None, max_iterations: int | None, prompts_dir: str | None, agents_dir: str | None, codex_review: bool | None, review_executor: ReviewExecutorConfig | None, proxy: str | None, hosts: dict[str, str])": location: config.py annotations: | Build execution configuration. Constructed by load_project_config from the build section of .goga/config.yml. @@ -303,8 +340,6 @@ Annotations: | Rate-limit retry wait duration. Go duration format. "max_iterations -> int | None": | Maximum number of task iterations. - "review_patience -> int | None": | - Stop review after N consecutive rounds with no changes. "prompts_dir -> str | None": | Custom ralphex prompt directory path. "agents_dir -> str | None": | @@ -351,12 +386,13 @@ Annotations: | Passed to the AI executor at launch to configure models and endpoints. Optional — defaults to an empty dict. -"ReviewExecutorConfig(skip: bool | None, agent: str | None, roles: list[str] | None, env: dict[str, str])": +"ReviewExecutorConfig(skip: bool | None, agent: str | None, roles: list[str] | None, env: dict[str, str], base_ref: str | None, patience: int | None)": location: config.py annotations: | Review-executor configuration of the build — whether the review phase is - skipped, which agent runs it, which reviewer roles participate, and which - environment variables the review pass carries. + skipped, which agent runs it, which reviewer roles participate, which + environment variables the review pass carries, the base of the review + diff, and the external-review stop threshold. `skip`: tri-state source for skipping the review phase — None when the field is absent @@ -366,6 +402,8 @@ Annotations: | stored verbatim and means the full default set to the consumer `env`: environment variable dictionary for the review pass — an empty dict when the field is absent, YAML-null, or an empty mapping + `base_ref`: review diff base — None when unset + `patience`: external-review stop threshold — None when unset Requirements: - Immutable frozen dataclass (frozen=True, kw_only=True), per `convention` @@ -376,6 +414,8 @@ Annotations: | Constraints: - Do not validate role names, agent names, or env applicability at this level — structural typing only + - Do not validate base_ref or patience values at this level — branch + resolvability and threshold semantics belong to the consumer properties: "skip -> bool | None": | Tri-state source for skipping the review phase. None when the field is @@ -392,6 +432,18 @@ Annotations: | from .goga/config.yml. Empty dict when the field is absent, YAML-null, or an empty mapping. Structural typing is enforced by `load_project_config`; the env-requires-agent rule belongs to the consumer. + "base_ref -> str | None": | + Review diff base for the review pass — a branch name or a commit + hash, verbatim from .goga/config.yml. None when the field is absent, + YAML-null, or empty/whitespace-only (normalized by + `load_project_config`). No resolution or validation here — the value + flows to the consumer as the source of the review-scoped diff base. + "patience -> int | None": | + External-review stop threshold — stop the external review after N + consecutive unchanged rounds. None when the field is absent or + YAML-null. Structural typing (int) is enforced by + `load_project_config`; range and semantic checks belong to the + consumer. "PipelineConfig(agent: str | None, env: dict, proxy: str | None, hosts: dict[str, str])": location: config.py @@ -483,6 +535,40 @@ Annotations: | List of exact relative paths excluded from AST traversal during goga lint. Empty list when lint.ignore is absent/empty. +"TopicsConfig(base_ref: str | None, publish_commit: str | None)": + location: config.py + annotations: | + Fast-creation configuration of the topics section of .goga/config.yml: + the base every published topic branch starts from and the commit + message template of the publication. + + `base_ref`: any revision string the base of a published branch resolves + from — verbatim, None when unset + `publish_commit`: the commit message template of the publication, with + or without the {slug} placeholder — verbatim, None + when unset + + Requirements: + - Immutable frozen dataclass (frozen=True, kw_only=True), per + `convention` + - Fields are stored verbatim — the empty-to-None normalization belongs + to `load_project_config`, the default template and the placeholder + substitution to the consumer + + Constraints: + - Do not resolve the revision string or validate the template grammar + at this level — structural typing belongs to `load_project_config`, + semantics to the consumer + properties: + "base_ref -> str | None": | + The base revision of a published topic branch, verbatim from + .goga/config.yml. None when the field is absent, YAML-null, or + empty/whitespace-only (normalized by `load_project_config`). + "publish_commit -> str | None": | + The commit message template of the publication, verbatim from + .goga/config.yml — the {slug} placeholder is optional. None when the + field is absent, YAML-null, or empty/whitespace-only. + "DepConfig(git: str, ref: str | None, root: str | None = None)": location: config.py annotations: | @@ -526,6 +612,4 @@ CreatedAt: 24/07/26 Description: | Project configuration model + loader for .goga/config.yml. Structural - validation only; semantic validation of version-grammar values, of the - `lint.ignore` paths, and of the `build.review_executor` fields is deferred - to the owning consumer. + validation only; semantic validation is deferred to the owning consumers. diff --git a/goga/config/project/__init__.py b/goga/config/project/__init__.py index f0d2a3ed..29c57088 100644 --- a/goga/config/project/__init__.py +++ b/goga/config/project/__init__.py @@ -5,6 +5,7 @@ ProjectConfig, ReviewExecutorConfig, TaskExecutorConfig, + TopicsConfig, ) from .loader import load_project_config @@ -15,5 +16,6 @@ "ProjectConfig", "ReviewExecutorConfig", "TaskExecutorConfig", + "TopicsConfig", "load_project_config", ] diff --git a/goga/config/project/config.py b/goga/config/project/config.py index 96bde57e..d895b56c 100644 --- a/goga/config/project/config.py +++ b/goga/config/project/config.py @@ -66,12 +66,19 @@ class ReviewExecutorConfig: ``env`` is the review-pass environment layer, stored verbatim from ``.goga/config.yml``: an empty dict when the field is absent, YAML-null, or an empty mapping. The env-requires-agent rule belongs to the consumer, not here. + + The section also carries the review diff base (``base_ref``) and the + external-review stop threshold (``patience``). Both are stored verbatim — + structural typing only: branch resolvability and threshold semantics belong to + the consumer, never to this dataclass or the loader. """ skip: bool | None = None agent: str | None = None roles: list[str] | None = None env: dict[str, str] = field(default_factory=dict) + base_ref: str | None = None + patience: int | None = None @dataclass(kw_only=True, frozen=True) @@ -85,7 +92,6 @@ class BuildConfig: idle_timeout: str | None = None wait: str | None = None max_iterations: int | None = None - review_patience: int | None = None prompts_dir: str | None = None agents_dir: str | None = None codex_review: bool | None = None @@ -106,6 +112,33 @@ class LintConfig: ignore: list[str] +@dataclass(kw_only=True, frozen=True) +class TopicsConfig: + """Fast-creation configuration of the topics section of `.goga/config.yml`. + + Immutable verbatim value-object. Fields are stored exactly as parsed: no + revision resolution, no template grammar checks, and no empty-to-None + normalization (that rule belongs to the loader, which always passes both + fields). Both fields may be `None` — a present-but-empty section means + "everything unset" (explicit absence). + + `base_ref`: any revision string the base of a published branch resolves + from — verbatim, None when unset + `publish_commit`: the commit message template of the publication, with + or without the {slug} placeholder — verbatim, None when + unset + + Args: + base_ref: The base revision of a published topic branch, verbatim + from `.goga/config.yml`; None when absent/YAML-null/empty. + publish_commit: The commit message template of the publication, + verbatim from `.goga/config.yml`; None when absent/YAML-null/empty. + """ + + base_ref: str | None + publish_commit: str | None + + @dataclass(kw_only=True, frozen=True) class ProjectConfig: """Root project configuration loaded from .goga/config.yml.""" @@ -120,3 +153,4 @@ class ProjectConfig: tools: dict[str, str] | None = None usages: dict[str, dict[str, DepConfig]] | None = None lint: LintConfig | None = None + topics: TopicsConfig | None = None diff --git a/goga/config/project/loader.py b/goga/config/project/loader.py index 11221a27..8e43d320 100644 --- a/goga/config/project/loader.py +++ b/goga/config/project/loader.py @@ -11,6 +11,7 @@ ProjectConfig, ReviewExecutorConfig, TaskExecutorConfig, + TopicsConfig, ) @@ -184,6 +185,71 @@ def _parse_lint(data: dict) -> LintConfig | None: return LintConfig(ignore=ignore_list) +def _parse_topics_field(value, key: str) -> str | None: + """Parse a single string field of the optional ``topics`` section. + + Mirrors the ``_parse_optional_agent`` / ``_parse_review_scoped_fields`` + normalization: an unset field (absent or YAML-null) resolves to ``None``, + an empty or whitespace-only string strips to ``None``, and a present + non-string value is a structural type error. A non-empty string is stored + verbatim — no revision resolution, no template grammar checks. + + Args: + value: The raw field value from the ``topics`` mapping (a ``str``, or + None when absent). + key: The dotted field name for error messages (e.g. + ``"topics.base_ref"``). + + Returns: + The stripped field value, or ``None`` when unset/empty. + + Raises: + ValueError: When ``value`` is present but not a string. + """ + if value is None: + return None + if not isinstance(value, str): + raise ValueError(f"{key} must be a string in .goga/config.yml") + + return value.strip() or None + + +def _parse_topics(data: dict) -> TopicsConfig | None: + """Parse the optional ``topics`` section into a ``TopicsConfig`` value-object. + + Structural-only parse mirroring the style of ``_parse_lint``: the section + is optional — absent or YAML-null resolves to ``None``, while a + present-but-empty mapping yields a ``TopicsConfig`` with both fields + ``None`` (a present section means "the section exists", not "unset"). + Unknown keys inside the mapping are ignored (the cell-wide stance — same + as ``lint``, ``codemanifest``, ``review_executor``). Rev resolvability, + template grammar, and the default template belong to the consuming + command, never to this loader. + + Args: + data: The already-parsed ``.goga/config.yml`` document. + + Returns: + A ``TopicsConfig`` storing both fields verbatim, or ``None`` when the + ``topics`` section is absent or YAML-null. + + Raises: + ValueError: When ``topics`` is present but not a mapping, or when + ``topics.base_ref``/``topics.publish_commit`` is present but not + a string. + """ + raw = data.get("topics") + if raw is None: + return None + if not isinstance(raw, dict): + raise ValueError("'topics' must be a mapping in .goga/config.yml") + + base_ref = _parse_topics_field(raw.get("base_ref"), "topics.base_ref") + publish_commit = _parse_topics_field(raw.get("publish_commit"), "topics.publish_commit") + + return TopicsConfig(base_ref=base_ref, publish_commit=publish_commit) + + def _parse_tools(data: dict) -> dict[str, str] | None: """Extract the optional top-level tools mapping. @@ -391,9 +457,53 @@ def _optional_mapping(data: dict, key: str) -> dict | None: return None if not isinstance(section, dict): raise ValueError(f"'{key}' must be a mapping in .goga/config.yml") + return section +def _parse_review_scoped_fields(raw: dict) -> tuple[str | None, int | None]: + """Parse the review-scoped ``base_ref``/``patience`` pair of ``review_executor``. + + Structural typing only, mirroring ``_parse_optional_agent``: ``base_ref`` is + stored stripped (an empty or whitespace-only string resolves to None) and a + present non-string is a type error. ``patience`` must be a real int — the + bool check precedes the int check because ``isinstance(True, int)`` is True, + so a YAML ``true`` is rejected instead of slipping through as ``1``. Both + are stored verbatim beyond that gate: no range checks, no + branch-resolvability checks (those belong to the consumer). + + Args: + raw: The already-parsed ``review_executor`` mapping. + + Returns: + The ``(base_ref, patience)`` pair, each None when its key is absent or + YAML-null. + + Raises: + ValueError: When ``base_ref`` is present but not a string, or when + ``patience`` is present but not an int (a bool included). + """ + base_ref_raw = raw.get("base_ref") + + if base_ref_raw is None: + base_ref = None + elif not isinstance(base_ref_raw, str): + raise ValueError("build.review_executor.base_ref must be a string in .goga/config.yml") + else: + base_ref = base_ref_raw.strip() or None + + patience_raw = raw.get("patience") + + if patience_raw is None: + patience = None + elif isinstance(patience_raw, bool) or not isinstance(patience_raw, int): + raise ValueError("build.review_executor.patience must be an int in .goga/config.yml") + else: + patience = patience_raw + + return base_ref, patience + + def _parse_review_executor(build_data: dict) -> ReviewExecutorConfig | None: """Parse the optional ``build.review_executor`` section (loader step 6.5). @@ -411,6 +521,9 @@ def _parse_review_executor(build_data: dict) -> ReviewExecutorConfig | None: whitelists and no env semantics live here — validation beyond structure belongs to the consumer. + The review-scoped pair (``base_ref``/``patience``) is parsed by + ``_parse_review_scoped_fields`` under the same structural-only stance. + Args: build_data: The already-parsed ``build`` mapping. @@ -421,8 +534,8 @@ def _parse_review_executor(build_data: dict) -> ReviewExecutorConfig | None: Raises: ValueError: When the section is present but not a mapping, or when - ``skip``/``agent``/``roles``/``env`` is present with an invalid - type. + ``skip``/``agent``/``roles``/``env``/``base_ref``/``patience`` is + present with an invalid type. """ raw = build_data.get("review_executor") @@ -459,7 +572,16 @@ def _parse_review_executor(build_data: dict) -> ReviewExecutorConfig | None: else: env = dict(env_raw) - return ReviewExecutorConfig(skip=skip, agent=agent, roles=roles, env=env) + base_ref, patience = _parse_review_scoped_fields(raw) + + return ReviewExecutorConfig( + skip=skip, + agent=agent, + roles=roles, + env=env, + base_ref=base_ref, + patience=patience, + ) def _parse_build(build_data: dict) -> BuildConfig: @@ -492,7 +614,6 @@ def _parse_build(build_data: dict) -> BuildConfig: idle_timeout=build_data.get("idle_timeout"), wait=build_data.get("wait"), max_iterations=build_data.get("max_iterations"), - review_patience=build_data.get("review_patience"), prompts_dir=build_data.get("prompts_dir"), agents_dir=build_data.get("agents_dir"), codex_review=build_data.get("codex_review"), @@ -550,6 +671,7 @@ def load_project_config() -> ProjectConfig: tools = _parse_tools(data) usages = _parse_usages(data.get("usages")) lint = _parse_lint(data) + topics = _parse_topics(data) return ProjectConfig( lang=lang, @@ -562,4 +684,5 @@ def load_project_config() -> ProjectConfig: tools=tools, usages=usages, lint=lint, + topics=topics, ) diff --git a/goga/history/.usages/history-tree.md b/goga/history/.usages/history-tree.md new file mode 100644 index 00000000..2709e32d --- /dev/null +++ b/goga/history/.usages/history-tree.md @@ -0,0 +1,34 @@ +# history — year and topic inventory + +How to walk the `.goga/history/` tree with the `goga.history` facade. +For consumers that inventory history: CLI list output, audits, cleanups. + +## Collecting the full tree + +```python +from goga.history import collect_history_tree + +tree = collect_history_tree() +for year_record in tree: + print(year_record.year) # "2026" + for topic in year_record.topics: # sorted alphabetically + print(topic) +``` + +## Collecting one year + +```python +from goga.history import collect_history_tree + +tree = collect_history_tree(year="2025") # one section only +for year_record in tree: + ... # exactly one HistoryYear when the year exists +``` + +- One `HistoryYear` per year, sorted by year ascending; topics within a + year sorted alphabetically. +- A year directory is a directory named with exactly four digits; anything + else in the history root is ignored. Only directories count as topics. +- An absent history root — or a year missing from the tree — yields an empty + list, not an error. +- The tree carries names only: no statuses, no artifact lists. diff --git a/goga/history/.usages/prune.md b/goga/history/.usages/prune.md new file mode 100644 index 00000000..e8b4697e --- /dev/null +++ b/goga/history/.usages/prune.md @@ -0,0 +1,70 @@ +# history — pruning orphan topics + +How to clean up the orphan topics of a year with the `goga.history` +facade. For consumers that maintain the tree: CLI cleanup commands, +maintenance scripts. + +A topic is an orphan when no branch of the repository inventory hosts +its slug: a local branch whose name normalizes to it, or a +remote-tracking ref whose short name — the part after the first "/" — +normalizes to it. The protection +is year-independent: a branch protects same-named topics of every year. +Deletion is unconditional — no status protects a topic, and the whole +topic directory goes with all of its artifacts. The tree lives outside +git, so a deleted topic directory is unrecoverable; run the dry pass +first. + +## Pruning a year + +```python +from goga.history import prune_topics + +candidates = prune_topics(dry_run=True) # lists candidates, deletes nothing +removed = prune_topics() # current year, deletes orphans +removed = prune_topics(year="2025") # an explicit year +print("\n".join(removed)) +``` + +- One slug per result entry, sorted alphabetically; an empty result is + an empty list — not an error. +- The slugs come out normalized: a manually unnormalized directory name + is listed yet stays on disk — only the normalized twin path is deleted. + Treat the list as the candidate set, not a receipt of deletions. +- `year` defaults to the current year; other years are never touched. +- Filesystem-only: no branch, ref, or index of git is mutated in any + mode. The ref listing is the one git call of the flow; its failures — + a git infrastructure error or a missing git binary — propagate to the + caller, so wrap them where a clean message is required. + +## Reading the branch inventory + +The protection oracle of the prune is the full branch inventory, read +with `list_branch_refs`: + +```python +from goga.history import BranchRef, list_branch_refs + +refs = list_branch_refs() +for ref in refs: + print(ref.name, "remote" if ref.remote else "local") +``` + +- Local branches and remote-tracking refs come back in one list, + sorted by display name; `name` is `<remote>/<branch>` for a + remote-tracking ref, and a local branch and its remote twin stay two + distinct refs. +- Read-only and offline — the refs as they exist locally; a git failure + of the listing propagates to the caller. + +## Deleting one topic directory + +```python +from goga.history import remove_topic_dir + +existed = remove_topic_dir("release-1-3-0", year="2025") +``` + +- True when the directory existed and was deleted, False when it was + absent — idempotent absence. +- Deletes the whole directory with every artifact inside; the orphan + decision belongs to the caller. diff --git a/goga/history/.usages/registering-statuses.md b/goga/history/.usages/registering-statuses.md new file mode 100644 index 00000000..9e48dac2 --- /dev/null +++ b/goga/history/.usages/registering-statuses.md @@ -0,0 +1,41 @@ +# history — registering topic statuses + +How a `goga_tool_*` package attaches its own statuses to the topic status +scale. For tool package authors; no goga code changes are needed. + +Subscribe a hook to the status action of the statuses domain inside your +`register_hooks` callback: + +```python +# inside the goga_tool_<tool> package +def register_hooks(hooks): + hooks.subscribe("statuses", "register_statuses", "published", register_published) + + +def register_published(context): + context.register("published", "mkdocs/published.md", after="planned") +``` + +The hook receives `context` — the registration surface scoped to your +tool. Every name you register is stored qualified with your tool prefix, +so registrations from different tools never collide and a topic can carry +several statuses at once. A hook may also declare `self` — the isolated +context of your tool; one instance links all its hook invocations of a +run, freely mutable, invisible to the domains. + +## Rules and failure behavior + +- The built-in statuses are immutable — registration is add-only. +- `name` — the status name as your tool defines it; shown as + `<tool>.<name>`. +- `filepath` — the artifact path relative to the topic directory; nested + paths allowed. +- `before` / `after` — anchors: qualified names of statuses this one + precedes or follows; at least one anchor is required, both given define + a placement range. +- A registration missing an anchor, carrying empty values, an + unresolvable anchor, or an invalid range is skipped with a log + warning; it never aborts the command and never cancels other + registrations. +- Two tools may reference the same artifact path — both statuses apply + independently. diff --git a/goga/history/.usages/topic-paths.md b/goga/history/.usages/topic-paths.md new file mode 100644 index 00000000..aa901ad4 --- /dev/null +++ b/goga/history/.usages/topic-paths.md @@ -0,0 +1,116 @@ +# history — topic paths and creation + +How to compute history topic paths and create topic directories with the +`goga.history` facade. For consumers that address the `.goga/history/` tree: +CLI commands, workflow scripts, and branch preparation. + +Every routine accepting a topic value normalizes it first: pass a branch name +(`release/1.3.0`) or an already-normalized slug (`release-1-3-0`) — both give +`.goga/history/<year>/release-1-3-0/`. An input that normalizes to an empty +slug raises a clean error; there is no fallback name. + +## Computing a topic directory + +```python +from goga.history import resolve_topic_dir + +topic_dir = resolve_topic_dir("Feature/Foo_Bar") # current year +# -> .goga/history/2026/feature-foo-bar + +topic_dir = resolve_topic_dir("release-1-3-0", year="2025") +# -> .goga/history/2025/release-1-3-0 +``` + +- Pure: nothing is created on disk. +- The year defaults to the current year (four digits, local time); + `None` and the empty string mean the current year. + +## Computing an artifact file path + +```python +from goga.history import resolve_topic_file + +plan = resolve_topic_file("history-commands", "plan.md") +# -> .goga/history/2026/history-commands/plan.md +todo_path = resolve_topic_file("feature-foo", "todo.md") +# -> .goga/history/2026/feature-foo/todo.md +``` + +- The filename is arbitrary but must carry an extension (`plan.md`); + a name without one is a clean error. +- The file is neither created nor checked for existence — the artifact's + producer writes it. + +## Checking whether a topic exists + +```python +from goga.history import topic_exists + +if topic_exists("release-1-3-0"): + ... # topic already occupied this year +``` + +- True only when the topic path exists as a directory; a stray file with the + slug's name does not occupy a topic. +- Read-only. + +## Creating a topic directory + +```python +from goga.history import ensure_topic_dir + +topic_dir = ensure_topic_dir("Feature/Foo_Bar") +# -> .goga/history/2026/feature-foo-bar (exists after the call) + +topic_dir = ensure_topic_dir("Feature/Foo_Bar", year="2025") +# -> .goga/history/2025/feature-foo-bar (exists after the call) +``` + +- The year defaults to the current year (four digits, local time); + `None` and the empty string mean the current year. +- Idempotent: an existing topic directory is a success, not a conflict. + Decide occupancy *before* creating (via `topic_exists`) when the + distinction matters. + +## The slug grammar + +`normalize_topic_slug` applies: lowercase → drop non-ASCII → everything +outside `[a-z0-9]` becomes `-` → collapse repeats → trim edges. + +```python +from goga.history import normalize_topic_slug + +normalize_topic_slug("Feature/Foo_Bar") # "feature-foo-bar" +normalize_topic_slug("release/1.3.0") # "release-1-3-0" +normalize_topic_slug("aБb") # "ab" +``` + +- Pure and deterministic; no transliteration; a fully non-ASCII name yields + an empty string — the caller decides what an empty slug means. + +## Reading the current branch as a topic source + +```python +from goga.history import resolve_current_branch_name + +branch = resolve_current_branch_name() +if branch is None: + ... # not a git repository / detached HEAD / git missing +topic_dir = resolve_topic_dir(branch) +``` + +- Returns the raw branch name, or None when it cannot be determined; the + error policy belongs to the caller. + +## Reading the history root + +```python +from goga.history import resolve_history_root + +root = resolve_history_root() # .goga/history/ +``` + +- The single source of the tree location — the prefix for reading topic + trees out of git refs. +- Pure: nothing is created. + diff --git a/goga/history/.usages/topic-statuses.md b/goga/history/.usages/topic-statuses.md new file mode 100644 index 00000000..43730c32 --- /dev/null +++ b/goga/history/.usages/topic-statuses.md @@ -0,0 +1,64 @@ +# history — topic statuses + +How to read the statuses of history topics with the `goga.history` facade. +For consumers that report progress: CLI status output, boards, reviews, +dashboards. + +A topic's status is the set of its maximal present statuses on the topic +status scale. The built-in axis is fixed — empty, todo, defined, discovered, +backlog, designed, specified, planned, done. `empty` is the floor for a +topic with no artifact at all; each of the other eight is marked by one +artifact inside the topic directory, in axis order — todo by todo.md, +defined by prd.md, discovered by adr.md, backlog by task.md, designed by +arch.md, specified by design.md, planned by plan.md, done by +completed/plan.md. Tool packages extend the scale +with qualified statuses `<tool>.<name>`, so one topic can carry several +statuses at once — all of them are shown. + +## Listing a year with statuses + +```python +from goga.history import assemble_status_scale, collect_topic_statuses + +records = collect_topic_statuses() # current year, scale assembled here +scale = assemble_status_scale() +records = collect_topic_statuses(year="2025", scale=scale) # reuse one scale +for record in records: + print(record.topic, " ".join(f"[{s}]" for s in record.statuses)) +``` + +- One `TopicRecord` per topic, sorted alphabetically by topic; the record + carries every maximal status name in scale order. +- Pass an assembled `scale` to reuse one assembly across calls; None + assembles it once inside. +- `year`: `None` and the empty string mean the current year; an absent + year or a year without topics yields an empty list — not an error. + +## Resolving one topic's statuses + +```python +from goga.history import ( + assemble_status_scale, + resolve_topic_dir, + resolve_topic_status, +) + +scale = assemble_status_scale() +statuses = resolve_topic_status(resolve_topic_dir("history-commands"), scale) +``` + +- Nested artifact paths are honored (completed/plan.md counts). +- Read-only. + +## Validating status names + +```python +from goga.history import assemble_status_scale + +scale = assemble_status_scale() +stage = scale.resolve_status("mkdocs.published") # unknown name -> clean error +``` + +- Use this to validate user-supplied status filters before matching records: + the member set is the assembled scale, and `stage.name` carries the + display name. diff --git a/goga/history/CODEMANIFEST b/goga/history/CODEMANIFEST new file mode 100644 index 00000000..0db82a6c --- /dev/null +++ b/goga/history/CODEMANIFEST @@ -0,0 +1,447 @@ +Imports: + - Types: + - resolve_current_branch_name + - BranchRef + - list_branch_refs + From: goga/history/git + - Types: + - StatusScale + - Stage + - StatusRegistry + - assemble_status_scale + From: goga/history/statuses + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell is the single owner of the .goga/history/ tree: topic identity + (the slug grammar and the current year), topic addressing (the tree root, + directory and artifact file paths, existence, creation, removal), the + topic status listing, tree traversal, and the orphan cleanup of one year + — the deletion of topic directories no branch of the repository + inventory hosts. The status scale is provided by the statuses subcell and + re-exported on this facade; the branch inventory is provided by the git + subcell and re-exported on this facade. Artifact files are written by + their producers — this cell computes paths and creates and removes topic + directories; it never writes artifact content. Pure filesystem and + grammar logic: no git access beyond the subcell re-exports, no CLI, no + output rendering. Every topic value received on the input is normalized — + a branch name and an already-normalized slug are both accepted, + identically and idempotently. Use relative imports. + +--- + +->resolve_current_branch_name: {} +->BranchRef: {} +->list_branch_refs: {} +->StatusScale: {} +->Stage: {} +->StatusRegistry: {} +->assemble_status_scale: {} + +"normalize_topic_slug(name: str) -> slug: str": + location: naming.py + annotations: | + Normalize a topic input into the history topic slug. + + `name`: topic input as entered — a branch name or an already-normalized slug + `slug`: the history topic slug + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Lowercase the name + 2. Drop every non-ASCII character (no transliteration) + 3. Replace each remaining character outside [a-z0-9] with a hyphen + 4. Collapse repeat hyphens into one + 5. Trim leading and trailing hyphens + + Requirements: + - "Feature/Foo_Bar" -> "feature-foo-bar"; "release/1.3.0" -> + "release-1-3-0"; "aБb" -> "ab" (non-ASCII dropped before hyphen + replacement); a fully non-ASCII name -> empty slug + - Idempotent — an already-normalized slug stays itself + - Deterministic — same input always produces the same output + - Pure string transformation — no git, no filesystem, no side effects + + Constraints: + - Do not transliterate Cyrillic or any other script + - Do not return a fallback for an empty result — an empty slug is a valid + output; the caller owns the empty-slug decision + +"current_year() -> year: str": + location: naming.py + annotations: | + Compute the current calendar year for history addressing. + + `year`: the current year as four digits + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Read the naive local calendar year and return it as four digits + + Requirements: + - Naive local time, exactly four digits — the single time source for every + history consumer + - Pure — no filesystem access, no caching + + Constraints: + - Do not accept a timezone or an override value — callers needing a + different year pass it explicitly to the path routines + +"resolve_history_root() -> root: Path": + location: paths.py + annotations: | + Compute the root path of the history tree. + + `root`: the history tree path .goga/history/ + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Compose .goga/history/ at the caller's working directory and return + it + + Requirements: + - The single source of the tree location for every consumer reading + the topic tree of a git ref + - Pure with respect to the filesystem — nothing is created or checked + + Constraints: + - Do not create the directory — creation belongs to `ensure_topic_dir` + +"resolve_topic_dir(topic: str, year: str | None = None) -> topic_dir: Path": + location: paths.py + annotations: | + Compute the directory path of a history topic. + + `topic`: topic input — a branch name or an already-normalized slug + `year`: optional year as four digits; None and the empty string mean + the current year + `topic_dir`: the topic directory path .goga/history/<year>/<slug>/ + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Normalize `topic` via `normalize_topic_slug` + 2. An empty slug -> raise a clean error (no topic name remains) + 3. Resolve the year — `year` when given, otherwise `current_year` + 4. Compose .goga/history/<year>/<slug> and return it + + Requirements: + - The path is rooted at the caller's working directory + - Pure with respect to the filesystem — the directory is not created + - Idempotent — the same input yields the same path + + Constraints: + - Do not create the directory — creation belongs to `ensure_topic_dir` + - Do not fall back to another name or year when the slug is empty — the + error is the result + +"resolve_topic_file(topic: str, filename: str, year: str | None = None) -> file_path: Path": + location: paths.py + annotations: | + Compute the path of an artifact file inside a history topic. + + `topic`: topic input — a branch name or an already-normalized slug + `filename`: artifact filename — arbitrary, must carry an extension + `year`: optional year as four digits; None and the empty string mean + the current year + `file_path`: the artifact file path .goga/history/<year>/<slug>/<filename> + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Reject a `filename` without an extension with a clean error + 2. Compose the topic directory via `resolve_topic_dir` with `topic` and `year` + 3. Append `filename` and return the path + + Requirements: + - A filename carries an extension when a dot separates a non-empty stem + from a non-empty suffix — a leading dot alone (a dotfile name such as + ".md") is a hidden-file marker, not an extension separator, and a + trailing dot alone ("plan.") is an empty extension, not a suffix + - Pure with respect to the filesystem — the file is neither created nor + checked for existence + - The filename is taken verbatim — no normalization, no case change + + Constraints: + - Do not resolve the path against the filesystem — no existence checks + +"topic_exists(topic: str, year: str | None = None) -> exists: bool": + location: paths.py + annotations: | + Decide whether a history topic already exists for the year. + + `topic`: topic input — a branch name or an already-normalized slug + `year`: optional year as four digits; None and the empty string mean + the current year + `exists`: True when the topic directory exists + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Compose the topic directory via `resolve_topic_dir` with `topic` and `year` + 2. Return True when the path exists and is a directory, otherwise False + + Requirements: + - A stray file named like the slug does not occupy a topic — only a + directory counts + - Read-only — nothing is created + + Constraints: + - Do not raise on an absent history root — a missing tree is simply "no" + +"ensure_topic_dir(name: str, year: str | None = None) -> topic_dir: Path": + location: paths.py + annotations: | + Create the directory of a history topic of a year. + + `name`: topic input — a branch name or an already-normalized slug + `year`: optional year as four digits; None and the empty string mean + the current year + `topic_dir`: the topic directory path that exists after the call + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Compose the topic directory via `resolve_topic_dir` with `name` and + `year` + 2. Create the directory including missing parents + 3. Return the path + + Requirements: + - Idempotent — an existing topic directory is a success, not a conflict + - Creates directories only — no artifact files + + Constraints: + - Do not report occupancy — deciding whether a topic may be created + belongs to the caller + - Do not create or touch artifact files inside the directory + +"remove_topic_dir(name: str, year: str | None = None) -> removed: bool": + location: paths.py + annotations: | + Delete the directory of a history topic of a year. + + `name`: topic input — a branch name or an already-normalized slug + `year`: optional year as four digits; None and the empty string mean + the current year + `removed`: True when the topic directory existed and was deleted, + False when it was absent + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Compose the topic directory via `resolve_topic_dir` with `name` and + `year` + 2. An absent directory yields False — idempotent absence, not an error + 3. Delete the directory with all of its content and return True + + Requirements: + - Deletes the whole topic directory — every artifact including nested + directories goes with it + - Pure filesystem mutation — no git branch, ref, or index is touched + + Constraints: + - Do not decide whether a topic deserves deletion — the orphan decision + belongs to the caller + - Do not touch sibling topic directories or the year directory itself + +"TopicRecord(topic: str, statuses: list[str])": + location: status.py + annotations: | + One topic of a year paired with its maximal present statuses — a single + record of the status listing. + + `topic`: the topic slug + `statuses`: the qualified names of the maximal present statuses, in + scale order + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "topic -> str": | + The topic slug — the directory name of the topic. + "statuses -> list[str]": | + The maximal present status names of the topic, in scale order. + +"resolve_topic_status(topic_dir: Path, scale: StatusScale) -> statuses: list[str]": + location: status.py + annotations: | + Resolve the maximal present statuses of one topic from its directory + content. + + `topic_dir`: the topic directory path + `scale`: the assembled status scale + `statuses`: the qualified names of the maximal present statuses, in + scale order + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. List the artifact paths present in the directory, relative to it + 2. Compute the maximal present statuses via `scale` + 3. No artifact present yields the single built-in name empty + + Requirements: + - Read-only — the directory content is read, never changed + - Nested artifact paths are honored — a status artifact may sit in a + subdirectory of the topic directory + + Constraints: + - Do not assemble the scale here — the caller owns the single assembly + per command run + - Do not consider files outside the scale + +"collect_topic_statuses(year: str | None = None, scale: StatusScale | None = None) -> records: list[TopicRecord]": + location: status.py + annotations: | + Collect every topic of one year with its maximal present statuses. + + `year`: optional year as four digits; None and the empty string mean + the current year + `scale`: optional assembled status scale; None assembles it once here + `records`: one `TopicRecord` per topic, sorted alphabetically by topic + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Resolve the year — `year` when given, otherwise the current year + 2. Resolve the scale — `scale` when given, otherwise assemble it once + 3. List the topic directories of that year; an absent year yields no + records + 4. Resolve the statuses of each topic via `resolve_topic_status` + 5. Assemble the records sorted alphabetically by topic and return them + + Requirements: + - Only directories count as topics — stray files in the year directory + are ignored + - An absent year yields an empty list — not an error + + Constraints: + - Do not filter — filtering belongs to the consumer + - Do not render — output shaping belongs to the consumer + +"HistoryYear(year: str, topics: list[str])": + location: tree.py + annotations: | + One year of the history tree paired with its topic names — a single record + of the tree listing. + + `year`: the year as four digits + `topics`: the topic slugs of that year + + Apply the `convention` practice for the data-model rules and intra-package + imports. + properties: + "year -> str": | + The year as four digits — the directory name of the year. + "topics -> list[str]": | + The topic slugs found under the year, sorted alphabetically. + +"collect_history_tree(year: str | None = None) -> tree: list[HistoryYear]": + location: tree.py + annotations: | + Collect the history tree — every year with its topics, or the one + named year alone. + + `year`: optional year as four digits; None and the empty string mean + no year selection — the full tree; a year missing from the + tree yields an empty list — not an error + `tree`: one `HistoryYear` per selected year, sorted by year ascending + + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Resolve the year selection — `year` when it names a year, + otherwise no selection + 2. List the year directories of the history root + 3. Keep only the selected year directory when a selection exists + 4. For each kept year, list its topic directories + 5. Assemble one `HistoryYear` per kept year — topics sorted + alphabetically, years sorted ascending + 6. Return the assembled tree + + Requirements: + - A year directory is a directory named with exactly four digits — + anything else is ignored + - Only directories count as topics — stray files are ignored + - An absent history root yields an empty list — not an error + - A selected year absent from the tree yields an empty list — not + an error + + Constraints: + - Do not compute statuses — the tree carries topic names only + - Do not render — output shaping belongs to the consumer + +"prune_topics(year: str | None = None, dry_run: bool = False) -> removed: list[str]": + location: prune.py + annotations: | + Delete the orphan topics of one year — the topics no branch of the + repository inventory hosts. + + `year`: optional year as four digits; None and the empty string mean + the current year + `dry_run`: True lists the orphan topics without deleting anything + `removed`: the slugs of the orphan topics sorted alphabetically — the + deleted ones, or the deletion candidates under `dry_run`; + the slugs come out normalized — a manually unnormalized + directory name is listed yet not reachable for deletion + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Resolve the year — `year` when given, otherwise the current year + 2. Take the topics of the resolved year from the tree collected via + `collect_history_tree` — an absent year yields no topics + 3. Enumerate the repository branch inventory via `list_branch_refs` + 4. Build the hosted slug set: every local branch name normalized via + `normalize_topic_slug`, and the short name of every remote-tracking + ref — the part after the first "/" — normalized the same way + 5. The orphans are the year's topics whose slug is not in the hosted set + 6. `dry_run` False -> delete every orphan directory via + `remove_topic_dir`; True -> delete nothing + 7. Return the orphan slugs sorted alphabetically + + Requirements: + - A topic is protected when at least one branch of the inventory + normalizes to its slug — the protection is year-independent, a branch + protects same-named topics of every year + - The branch inventory is queried even when the resolved year holds no + topics; a git failure of the listing propagates to the caller + - Deletion is unconditional — no status protects a topic + - Only the resolved year is affected — no other year is touched + - Filesystem-only — no branch, ref, or index of git is mutated in any + mode + - `dry_run` True mutates nothing at all + - An empty result is an empty list — not an error + + Constraints: + - Do not assemble the status scale — statuses take no part in the orphan + decision + - Do not spare topics by status or age + - Do not print — output shaping belongs to the consumer + +--- + +Author: Goga +CreatedAt: 28/08/26 +Description: | + Owner of the .goga/history/ tree — topic identity, addressing, status + listing, traversal, and orphan cleanup. Re-exports the git introspection + and the status scale on its facade. diff --git a/goga/history/__init__.py b/goga/history/__init__.py new file mode 100644 index 00000000..c9c3f945 --- /dev/null +++ b/goga/history/__init__.py @@ -0,0 +1,49 @@ +"""History domain cell — the single owner of the ``.goga/history/`` tree. + +Topic identity (the slug grammar and the current year), topic addressing +(directory and artifact file paths, existence, creation, and removal), the +topic status listing, tree traversal, and orphan cleanup. The git branch +reader and the branch inventory live in the nested leaf cell +``goga.history.git`` and the status scale in the ``goga.history.statuses`` +subcell — both re-exported on this facade, the embeddings declared in +``goga/history/CODEMANIFEST``. +""" + +from .git import BranchRef, list_branch_refs, resolve_current_branch_name +from .naming import current_year, normalize_topic_slug +from .paths import ( + ensure_topic_dir, + remove_topic_dir, + resolve_history_root, + resolve_topic_dir, + resolve_topic_file, + topic_exists, +) +from .prune import prune_topics +from .status import TopicRecord, collect_topic_statuses, resolve_topic_status +from .statuses import Stage, StatusRegistry, StatusScale, assemble_status_scale +from .tree import HistoryYear, collect_history_tree + +__all__: list[str] = [ + "BranchRef", + "HistoryYear", + "Stage", + "StatusRegistry", + "StatusScale", + "TopicRecord", + "assemble_status_scale", + "collect_history_tree", + "collect_topic_statuses", + "current_year", + "ensure_topic_dir", + "list_branch_refs", + "normalize_topic_slug", + "prune_topics", + "remove_topic_dir", + "resolve_current_branch_name", + "resolve_history_root", + "resolve_topic_dir", + "resolve_topic_file", + "resolve_topic_status", + "topic_exists", +] diff --git a/goga/history/git/CODEMANIFEST b/goga/history/git/CODEMANIFEST new file mode 100644 index 00000000..1791eb9a --- /dev/null +++ b/goga/history/git/CODEMANIFEST @@ -0,0 +1,106 @@ +Usages: + convention: .goga/usages/conventions.md + git: | + External git binary invoked via subprocess.run (check=True, capture_output=True). + Set GIT_TERMINAL_PROMPT=0 in the env to suppress interactive prompts. + Read-only inspection: the current branch name and the branch ref + inventory (local branches and remote-tracking refs). Mock the + subprocess call in tests per `convention`. + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns git-environment introspection for the history domain: reading + the current branch name and enumerating the repository branch inventory. It + is environment access, not history-path or topic logic — every decision + belongs to the caller. All git access flows through the `git` practice; + mock the subprocess call in tests per `convention`. Use relative imports. + +--- + +"BranchRef(name: str, remote: bool)": + location: refs.py + annotations: | + One branch ref of the repository inventory — a local branch or a + remote-tracking ref. + + `name`: the display name — the short branch name for a local ref, + <remote>/<branch> for a remote-tracking ref + `remote`: True when the ref is remote-tracking + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The display name is the identity used by consumers — no reshortening, + no normalization + properties: + "name -> str": | + The display branch name of the ref. + "remote -> bool": | + True when the ref is a remote-tracking ref. + +"list_branch_refs() -> refs: list[BranchRef]": + location: refs.py + annotations: | + Enumerate the branch refs of the repository — local branches and + remote-tracking refs together. + + `refs`: every branch ref, sorted alphabetically by display name + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git for the local branch refs + 2. Ask git for the remote-tracking refs + 3. Drop the <remote>/HEAD symrefs — they are pointers, not branches + 4. Merge both into one inventory sorted alphabetically by display name + + Requirements: + - Read-only — no ref is created, moved, or deleted + - No network — remote-tracking refs as they exist locally + - A git infrastructure failure of the listing raises + subprocess.CalledProcessError, a missing git binary OSError — both + propagate to the caller + + Constraints: + - Do not deduplicate — a local branch and its remote twin are two distinct + refs here; collapsing them belongs to the caller + +"resolve_current_branch_name() -> branch: str | None": + location: branch.py + annotations: | + Read the current git branch name exactly as git reports it. + + `branch`: raw current branch name, or None when it cannot be determined + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package imports. + + Algorithm: + 1. Ask git for the current branch name + 2. A non-empty answer -> return it stripped, unmodified + 3. Detached HEAD, missing git binary, or a non-repository -> None + + Requirements: + - Read-only — no branch switch, no writes, no caching + - No slugification and no fallback value — both belong to the caller + + Constraints: + - Do not tolerate unexpected OS-level failures silently — the None result + covers only the documented failure modes + +--- + +Author: Goga +CreatedAt: 28/08/26 +Description: | + Git-environment introspection for the history domain — the current branch + name and the branch inventory. Re-exported on the goga/history facade. diff --git a/goga/history/git/__init__.py b/goga/history/git/__init__.py new file mode 100644 index 00000000..adf25997 --- /dev/null +++ b/goga/history/git/__init__.py @@ -0,0 +1,6 @@ +"""Git-environment introspection cell for the history domain — the branch reader, the branch inventory.""" + +from .branch import resolve_current_branch_name +from .refs import BranchRef, list_branch_refs + +__all__: list[str] = ["BranchRef", "list_branch_refs", "resolve_current_branch_name"] diff --git a/goga/history/git/branch.py b/goga/history/git/branch.py new file mode 100644 index 00000000..9817fb21 --- /dev/null +++ b/goga/history/git/branch.py @@ -0,0 +1,49 @@ +"""Git-environment introspection for the history domain — the branch reader. + +The single routine declared in the cell CODEMANIFEST with ``location: +branch.py``: the raw current-branch reader. Every git invocation follows the +``git`` practice — ``subprocess.run`` with ``check=True``, captured output, and +``GIT_TERMINAL_PROMPT=0`` in the environment. The reader is read-only; no +branch is switched and nothing is written. +""" + +from __future__ import annotations + +import os +import subprocess + + +def resolve_current_branch_name() -> str | None: + """Read the current git branch name exactly as git reports it. + + Asks git via ``git branch --show-current`` (per the ``git`` practice) and + returns the stripped answer unmodified — no slugification, no fallback + value; both belong to the caller. ``None`` covers only the three documented + failure modes: detached HEAD (an empty git answer), a missing git binary + (``FileNotFoundError``), and a non-repository (a non-zero git exit). + Read-only; the result is not cached — each call asks git anew. + + Returns: + The raw current branch name (stripped, unmodified), or ``None`` when it + cannot be determined. + + Raises: + OSError: unexpected OS-level failures of the git invocation (e.g. a + ``PermissionError``); the ``None`` result covers only the + documented failure modes. + """ + try: + result = subprocess.run( + ["git", "branch", "--show-current"], + check=True, + capture_output=True, + text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return None + value = result.stdout.strip() + if value == "": + return None + + return value diff --git a/goga/history/git/refs.py b/goga/history/git/refs.py new file mode 100644 index 00000000..abee3777 --- /dev/null +++ b/goga/history/git/refs.py @@ -0,0 +1,100 @@ +"""The branch-ref inventory of the history-domain git cell. + +The entities declared in the cell CODEMANIFEST with ``location: refs.py``: +one branch ref of the repository inventory — a local branch or a +remote-tracking ref — and the read-only enumerator that merges both kinds +into one alphabetically sorted inventory. Every git invocation follows the +``git`` practice — ``subprocess.run`` with ``check=True``, captured output, +and ``GIT_TERMINAL_PROMPT=0`` in the environment. +""" + +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass + + +@dataclass(frozen=True, kw_only=True) +class BranchRef: + """One branch ref of the repository inventory. + + Attributes: + name: The display name — the short branch name for a local ref, + ``<remote>/<branch>`` for a remote-tracking ref. + remote: ``True`` when the ref is remote-tracking. + + Requirements: + The display name is the identity used by consumers — no + reshortening, no normalization. + """ + + name: str + remote: bool + + +def list_branch_refs() -> list[BranchRef]: + """Enumerate the branch refs of the repository. + + Asks git for the local branches and the remote-tracking refs (as they + exist locally — no network), drops the ``*/HEAD`` symrefs, and merges + both answers into one inventory sorted alphabetically by display name. + A local branch and its remote twin stay two distinct refs — collapsing + them belongs to the caller. + + Returns: + Every branch ref, sorted alphabetically by display name. + + Algorithm: + 1. Ask git for the local branch refs + 2. Ask git for the remote-tracking refs + 3. Drop the ``<remote>/HEAD`` symrefs of the answers — they are + pointers, not branches + 4. Merge both into one inventory sorted alphabetically by display + name + + Requirements: + Read-only — no ref is created, moved, or deleted. + + No network — remote-tracking refs as they exist locally. + + Constraints: + Do not deduplicate — a local branch and its remote twin are two + distinct refs here; collapsing them belongs to the caller. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + ref listing itself (propagated — the caller wraps it). + OSError: unexpected OS-level failures of the git invocations (e.g. a + missing git binary). + """ + local = _refs_under("refs/heads", remote=False) + tracked = _refs_under("refs/remotes", remote=True) + return sorted([*local, *tracked], key=lambda ref: ref.name) + + +def _refs_under(ref_prefix: str, remote: bool) -> list[BranchRef]: + """Run one ``for-each-ref`` invocation and parse it into branch refs. + + Args: + ref_prefix: The ref namespace to list — ``refs/heads`` or + ``refs/remotes``. + remote: Whether the listed refs are remote-tracking. + + Returns: + The parsed refs of the namespace, in git order. Refs whose display + name ends with ``/HEAD`` (the ``<remote>/HEAD`` symrefs) are not + branches and are dropped. + """ + result = subprocess.run( + ["git", "for-each-ref", "--format=%(refname:short)", ref_prefix], + check=True, + capture_output=True, + text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + return [ + BranchRef(name=line, remote=remote) + for line in result.stdout.splitlines() + if line and not line.endswith("/HEAD") + ] diff --git a/goga/history/naming.py b/goga/history/naming.py new file mode 100644 index 00000000..432db12c --- /dev/null +++ b/goga/history/naming.py @@ -0,0 +1,54 @@ +"""Naming and time primitives for the history domain. + +The two routines declared in the cell CODEMANIFEST with ``location: +naming.py``: the pure slug transformer — the single owner of the topic slug +grammar — and the single current-year point shared by every history +consumer. Both are pure — no git, no filesystem, no caching. +""" + +from __future__ import annotations + +import re +from datetime import datetime + + +def normalize_topic_slug(name: str) -> str: + """Normalize a branch name into the history topic slug. + + Deterministic pure string transformation: lowercase the name, drop every + non-ASCII character (no transliteration), replace each remaining character + outside ``[a-z0-9]`` with a hyphen, collapse repeat hyphens into one, and + trim leading and trailing hyphens. Lowercasing happens BEFORE the ASCII + filter, so a name like ``"aБb"`` yields ``"ab"`` and the Turkish dotted + capital ``"İ"`` lowercases to ``"i"`` plus a combining dot that the filter + drops. + + A fully non-ASCII or all-separator name yields the empty string — a valid + output. No fallback is returned for an empty result; the caller owns the + empty-slug decision. + + Args: + name: Branch name as entered by the user. + + Returns: + The history topic slug (possibly empty). No git, no filesystem, no + side effects. + """ + lowered = name.lower() + ascii_only = "".join(character for character in lowered if character.isascii()) + hyphened = re.sub(r"[^a-z0-9]", "-", ascii_only) + collapsed = re.sub(r"-{2,}", "-", hyphened) + return collapsed.strip("-") + + +def current_year() -> str: + """Return the current local calendar year as a 4-digit string. + + The single time point for every history consumer: naive local time — the + history tree is organized by the host's calendar year — with no timezone + and no override. Pure and uncached: evaluated anew on each call. + + Returns: + The current local calendar year, four digits, as a string. + """ + return f"{datetime.now().year:04d}" # noqa: DTZ005 — bare now() is the mandated test mock target diff --git a/goga/history/paths.py b/goga/history/paths.py new file mode 100644 index 00000000..c5006d7e --- /dev/null +++ b/goga/history/paths.py @@ -0,0 +1,167 @@ +"""Topic addressing for the history domain. + +The routines declared in the cell CODEMANIFEST with ``location: paths.py``: +the public history-root composer (the private helper delegates to it), the +two pure path composers (topic directory and artifact file), the read-only +occupancy oracle, the idempotent directory creator, and the idempotent +directory remover. Composers never touch the filesystem — creation belongs +to ``ensure_topic_dir`` alone, deletion to ``remove_topic_dir`` alone. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path, PurePath + +from .naming import current_year, normalize_topic_slug + + +def resolve_history_root() -> Path: + """Return the root path of the history tree. + + The single source of the tree location for every consumer reading the + topic tree of a git ref — the path is composed at the caller's working + directory and nothing is created or checked. + + Returns: + The history tree path ``.goga/history/`` — relative to the caller's + working directory, not created. + """ + return Path(".goga") / "history" + + +def _history_root() -> Path: + """Return the history tree root — delegated to the public composer.""" + return resolve_history_root() + + +def resolve_topic_dir(topic: str, year: str | None = None) -> Path: + """Compute the directory path of a history topic. + + The topic input is normalized via the slug grammar — a branch name and an + already-normalized slug compose identically. A falsy year (``None`` or the + empty string an empty CLI value produces) means "not set" and falls back + to the current year; without that rule an empty string would degrade the + composed path to the year's parent directory. + + Args: + topic: Topic input — a branch name or an already-normalized slug. + year: Optional year as four digits; ``None`` and the empty string mean the current year. + + Returns: + The topic directory path ``.goga/history/<year>/<slug>/`` — relative + to the caller's working directory, not created. + + Raises: + ValueError: The topic input normalizes to an empty slug — no fallback + name or year is returned; the error is the result. + """ + slug = normalize_topic_slug(topic) + if slug == "": + raise ValueError(f"topic input {topic!r} normalizes to an empty topic slug") + resolved_year = year or current_year() + return _history_root() / resolved_year / slug + + +def resolve_topic_file(topic: str, filename: str, year: str | None = None) -> Path: + """Compute the path of an artifact file inside a history topic. + + The filename is taken verbatim — no normalization, no case change — and + must carry an extension: a dot separating a non-empty stem from a + non-empty suffix. A leading dot alone (a dotfile name such as ``.md``) is + a hidden-file marker, not an extension separator, and a trailing dot + alone (``plan.``) is an empty extension — both are rejected explicitly, + because ``PurePath.suffix`` reports a lone trailing dot as the suffix + ``"."`` starting from Python 3.14. + + Args: + topic: Topic input — a branch name or an already-normalized slug. + filename: Artifact filename — arbitrary, must carry an extension. + year: Optional year as four digits; ``None`` and the empty string mean the current year. + + Returns: + The artifact file path ``.goga/history/<year>/<slug>/<filename>`` — + neither created nor checked for existence. + + Raises: + ValueError: The filename carries no extension, or the topic input + normalizes to an empty slug (the directory composer's error). + """ + if PurePath(filename).suffix in ("", "."): + raise ValueError(f"filename {filename!r} must carry an extension") + + return resolve_topic_dir(topic, year) / filename + + +def topic_exists(topic: str, year: str | None = None) -> bool: + """Decide whether a history topic already exists for the year. + + True only when the composed topic path exists as a directory — a stray + file named like the slug does not occupy a topic, and a missing history + root is simply "no", not an error. + + Args: + topic: Topic input — a branch name or an already-normalized slug. + year: Optional year as four digits; ``None`` and the empty string mean the current year. + + Returns: + True when the topic directory exists, otherwise False. + """ + return resolve_topic_dir(topic, year).is_dir() + + +def ensure_topic_dir(name: str, year: str | None = None) -> Path: + """Create the directory of a history topic of a year. + + Idempotent: an existing topic directory is a success, not a conflict — + deciding whether a topic may be created belongs to the caller. + Directories only: no artifact file inside the tree is created or touched. + + Args: + name: Topic input — a branch name or an already-normalized slug. + year: Optional year as four digits; ``None`` and the empty string mean the current year. + + Returns: + The topic directory path that now exists. + + Raises: + ValueError: The name normalizes to an empty slug. + OSError: Propagated from ``mkdir`` — unexpected OS failures are not + swallowed. + """ + topic_dir = resolve_topic_dir(name, year) + topic_dir.mkdir(parents=True, exist_ok=True) + return topic_dir + + +def remove_topic_dir(name: str, year: str | None = None) -> bool: + """Delete the directory of a history topic of a year. + + The whole topic directory goes — every artifact including nested + directories such as ``completed/`` — and nothing else: sibling topic + directories and the year directory itself stay untouched. Deciding + whether a topic deserves deletion belongs to the caller; this routine + only executes the decision. A pure filesystem mutation — no git branch, + ref, or index is touched. A stray file named like the slug does not + occupy a topic (the ``topic_exists`` semantics), so it yields False + and stays in place. + + Args: + name: Topic input — a branch name or an already-normalized slug. + year: Optional year as four digits; ``None`` and the empty string mean the current year. + + Returns: + True when the topic directory existed and was deleted, False when + it was absent. + + Raises: + ValueError: The name normalizes to an empty slug (the directory + composer's error). + OSError: Propagated from ``rmtree`` — unexpected OS failures are + not swallowed. + """ + topic_dir = resolve_topic_dir(name, year) + if not topic_dir.is_dir(): + return False + shutil.rmtree(topic_dir) + return True diff --git a/goga/history/prune.py b/goga/history/prune.py new file mode 100644 index 00000000..d312177b --- /dev/null +++ b/goga/history/prune.py @@ -0,0 +1,98 @@ +"""Orphan-topic cleanup for the history domain. + +The routine declared in the cell CODEMANIFEST with ``location: prune.py``: +the orphan computation and cleanup of one year of the history tree. This +module owns the orphan decision alone — the tree inventory comes from the +tree collector, the branch inventory from the nested git cell, and the +deletion itself from the directory remover. Filesystem-only: the single git +invocation of the flow is the read-only ref listing, and no branch, ref, or +index is mutated in any mode. +""" + +from __future__ import annotations + +from .git import list_branch_refs +from .naming import current_year, normalize_topic_slug +from .paths import remove_topic_dir +from .tree import collect_history_tree + + +def prune_topics(year: str | None = None, dry_run: bool = False) -> list[str]: + """Delete the orphan topics of one year — the topics no branch of the repository inventory hosts. + + Args: + year: Optional year as four digits — ``None`` and the empty string + mean the current year. + dry_run: ``True`` lists the orphan topics without deleting anything. + + Returns: + The slugs of the orphan topics sorted alphabetically — the deleted + ones, or the deletion candidates under ``dry_run``. The slugs come + out normalized: a manually unnormalized directory name is listed + yet not reachable for deletion. + + Algorithm: + 1. Resolve the year — ``year`` when given, otherwise the current year + 2. Take the topics of the resolved year from the tree collected via + ``collect_history_tree`` — an absent year yields no topics + 3. Enumerate the repository branch inventory via ``list_branch_refs`` + 4. Build the hosted slug set: every local branch name normalized via + ``normalize_topic_slug``, and the short name of every + remote-tracking ref — the part after the first ``/`` — normalized + the same way + 5. The orphans are the year's topics whose slug is not in the hosted + set + 6. ``dry_run`` False -> delete every orphan directory via + ``remove_topic_dir``; True -> delete nothing + 7. Return the orphan slugs sorted alphabetically + + Requirements: + A topic is protected when at least one branch of the inventory + normalizes to its slug — the protection is year-independent, a + branch protects same-named topics of every year. + + The branch inventory is queried even when the resolved year holds + no topics; a git failure of the listing propagates to the caller. + + Deletion is unconditional — no status protects a topic. + + Only the resolved year is affected — no other year is touched. + + Filesystem-only — no branch, ref, or index of git is mutated in any + mode. + + ``dry_run`` True mutates nothing at all. + + An empty result is an empty list — not an error. + + Constraints: + Do not assemble the status scale — statuses take no part in the + orphan decision. + + Do not spare topics by status or age. + + Do not print — output shaping belongs to the consumer. + + Raises: + ValueError: a topic directory name normalizes to an empty slug — + propagated from ``remove_topic_dir`` before the list is returned. + subprocess.CalledProcessError: a git infrastructure failure of the + ref listing (propagated — the caller wraps it). + FileNotFoundError: a missing git binary of the ref listing + (propagated — the caller wraps it). + OSError: unexpected filesystem failures of the deletion (propagated + — the caller wraps it). + """ + resolved_year = year or current_year() + tree = collect_history_tree(resolved_year) + year_topics: list[str] = tree[0].topics if tree else [] + hosted = { + normalize_topic_slug(ref.name.partition("/")[2] if ref.remote else ref.name) for ref in list_branch_refs() + } + orphans = sorted({normalize_topic_slug(topic) for topic in year_topics} - hosted) + + if not dry_run: + for slug in orphans: + remove_topic_dir(slug, resolved_year) + + return orphans diff --git a/goga/history/status.py b/goga/history/status.py new file mode 100644 index 00000000..a15fe391 --- /dev/null +++ b/goga/history/status.py @@ -0,0 +1,104 @@ +"""Topic status listing for the history domain. + +The entities declared in the cell CODEMANIFEST with ``location: status.py``: +the per-topic record of the status listing and the two read-only resolvers +that walk a topic directory and a whole year against the caller's assembled +status scale. Both routines only probe — nothing is created or changed; the +scale itself belongs to the statuses subcell and is assembled once per +command run, never per topic. Filtering and rendering belong to the consumer. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from .naming import current_year +from .paths import _history_root +from .statuses import StatusScale, assemble_status_scale + + +@dataclass(frozen=True, kw_only=True) +class TopicRecord: + """One topic of a year paired with its maximal present statuses. + + Attributes: + topic: The topic slug — the directory name of the topic. + statuses: The qualified names of the maximal present statuses, in + scale order. + """ + + topic: str + statuses: list[str] + + +def resolve_topic_status(topic_dir: Path, scale: StatusScale) -> list[str]: + """Resolve the maximal present statuses of one topic from its directory content. + + Args: + topic_dir: The topic directory path. + scale: The assembled status scale. + + Returns: + The qualified names of the maximal present statuses, in scale order. + Read-only — the directory content is read, never changed. A topic + with no artifact present yields the single built-in name ``empty``. + + Algorithm: + 1. List the artifact paths present in the directory, relative to it + 2. Compute the maximal present statuses via ``scale`` + 3. No artifact present yields the single built-in name empty + + Requirements: + Nested artifact paths are honored — a status artifact may sit in a + subdirectory of the topic directory. + + Constraints: + Do not assemble the scale here — the caller owns the single assembly + per command run. + Do not consider files outside the scale. + """ + paths = [path.relative_to(topic_dir).as_posix() for path in topic_dir.rglob("*") if path.is_file()] + return scale.maximal_present(paths) + + +def collect_topic_statuses(year: str | None = None, scale: StatusScale | None = None) -> list[TopicRecord]: + """Collect every topic of one year with its maximal present statuses. + + Args: + year: Optional year as four digits; ``None`` and the empty string + mean the current year. + scale: Optional assembled status scale; ``None`` assembles it once + here. + + Returns: + One ``TopicRecord`` per topic, sorted alphabetically by topic — the + full year, unfiltered. An absent year yields an empty list, not an + error; stray files in the year directory are not topics. + + Algorithm: + 1. Resolve the year — ``year`` when given, otherwise the current year + 2. Resolve the scale — ``scale`` when given, otherwise assemble it + once + 3. List the topic directories of that year; an absent year yields no + records + 4. Resolve the statuses of each topic via ``resolve_topic_status`` + 5. Assemble the records sorted alphabetically by topic and return + them + + Constraints: + Do not filter — filtering belongs to the consumer. + Do not render — output shaping belongs to the consumer. + """ + resolved_scale = scale or assemble_status_scale() + resolved_year = year or current_year() + year_dir = _history_root() / resolved_year + + if not year_dir.is_dir(): + return [] + + topics = sorted(path.name for path in year_dir.iterdir() if path.is_dir()) + + return [ + TopicRecord(topic=topic, statuses=resolve_topic_status(year_dir / topic, resolved_scale)) for topic in topics + ] diff --git a/goga/history/statuses/CODEMANIFEST b/goga/history/statuses/CODEMANIFEST new file mode 100644 index 00000000..6ce67bd0 --- /dev/null +++ b/goga/history/statuses/CODEMANIFEST @@ -0,0 +1,247 @@ +Imports: + - Types: + - HookRegistry + - emit_hook_event + - declared_actions + Usages: + - registering-hooks + - declaring-actions + From: goga/hooks + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the topic status scale: the built-in artifact axis, the + semantics of tool-status registration — what a registration may carry, + the anchoring and the add-only rule over the immutable built-in axis — + and the computation of a topic's maximal present statuses. Tool packages + reach the scale through the status action: the cell emits the action and + each subscribed tool registers through the registry delivered to its + hook. Pure scale logic — no package enumeration, no facade imports, no + callback invocation, no filesystem probing of topic directories, no git + access, no CLI, no output rendering. Use the `registering-hooks` + practice for the tool-facing registration contract behind the action. + Use the `declaring-actions` practice for the emission contract of the + action. Use relative imports. + +--- + +"StatusScale(stages: list[Stage])": + location: scale.py + annotations: | + The assembled partially ordered scale of topic statuses — the single + source of scale order and maximal-status computation. + + `stages`: the scale content in scale order + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The built-in axis is ordered empty, todo, defined, discovered, + backlog, designed, specified, planned, done by the artifacts + todo.md, prd.md, adr.md, task.md, arch.md, design.md, plan.md, + completed/plan.md + - The built-in empty entry carries the empty artifact path — one name + more than the artifact list: it is never markable and surfaces as + the single status of a topic with no artifact present + - A tool status never reorders or replaces a built-in one + properties: + "stages -> list[Stage]": | + The scale content in scale order. + methods: + "maximal_present(paths: list[str]) -> statuses: list[str]": | + Compute the maximal present statuses of one topic. + + `paths`: the artifact paths present in a topic directory, relative to + it + `statuses`: the qualified names of the maximal present statuses, in + scale order + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Mark every scale entry whose artifact path is present + 2. Drop every marked entry strictly below another marked entry + 3. Return the names of the surviving entries in scale order + + Requirements: + - Every maximal entry is returned — a present entry outranked by no + other present entry stays visible + - A topic with no artifact present yields the single built-in name + empty + + Constraints: + - Do not probe the filesystem — presence is decided by the caller's + `paths` input alone + "resolve_status(name: str) -> stage: Stage": | + Resolve a qualified status name for filter validation. + + `name`: a status name as entered by a consumer — a built-in name or a + qualified tool name + `stage`: the scale entry carrying the name + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Find the scale entry whose qualified name equals `name` exactly + 2. An unknown name raises a clean error + + Constraints: + - Do not fuzzy-match or fall back — the exact qualified name is the + contract + +"Stage(name: str, filepath: str, before: str | None = None, after: str | None = None)": + location: scale.py + annotations: | + One entry of the scale — a named position anchored to the artifact that + marks it. + + `name`: the qualified name — bare for built-in entries, <tool>.<name> + for tool entries + `filepath`: the artifact path relative to the topic directory; nested + paths allowed; empty only for the built-in empty entry — + such an entry is never marked present + `before`: the qualified name of the entry this one precedes; None for + built-in entries + `after`: the qualified name of the entry this one follows; None for + built-in entries + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - A tool entry carries at least one anchor; a built-in entry carries + none — its position is the axis order + - A filepath of the empty string belongs to the built-in empty entry + alone — no tool entry may register the unmarked floor + properties: + "name -> str": | + The qualified status name. + "filepath -> str": | + The artifact path relative to the topic directory. + "before -> str | None": | + The qualified name of the entry this one precedes, or None. + "after -> str | None": | + The qualified name of the entry this one follows, or None. + +"StatusRegistry(builtin_stages: list[Stage], tool_prefix: str)": + location: registry.py + annotations: | + The controlled registration surface handed to a tool package — the only + way a tool status enters the scale. One registry instance is the context + view delivered to the hook of one subscribed tool; the hook registers + through it and nothing else. + + `builtin_stages`: the immutable built-in axis the registry extends + `tool_prefix`: the qualifier applied to every name registered through + this registry — the tool identity of the receiving hook + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Registration is add-only — a built-in entry is never modified, + removed, or re-anchored + properties: + "stages -> list[Stage]": | + The built-in axis plus every accepted tool entry. + methods: + "register(name: str, filepath: str, before: str | None = None, after: str | None = None)": | + Register one tool status. + + `name`: the status name as the tool defines it — stored qualified as + <tool_prefix>.<name> + `filepath`: the artifact path relative to the topic directory + `before`: optional anchor — the qualified name of an entry this one + precedes + `after`: optional anchor — the qualified name of an entry this one + follows + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Qualify `name` with the registry's tool prefix + 2. Validate the content: a non-empty name, a non-empty `filepath`, + and at least one anchor present + 3. Reject a name already registered in this registry — the qualified + name is the identity, one entry per name + 4. Append the entry to the registry content + + Requirements: + - A structural violation raises a clean registration error naming the + entry + - Both anchors given define a placement range; anchor resolution and + range validity are decided at scale assembly + + Constraints: + - Do not resolve anchors here — a tool may anchor to an entry + registered by another tool + - Do not modify built-in entries + +"assemble_status_scale() -> scale: StatusScale": + location: assembly.py + annotations: | + Assemble the full status scale — the built-in axis extended by every + tool subscribed to the status action. + + `scale`: the assembled scale + + Apply the `convention` practice for docstring style and intra-package + imports. + Use the `declaring-actions` practice for the emission contract. + Use the `registering-hooks` practice for the registration contract + behind the action. + + Algorithm: + 1. Build the built-in axis of nine entries + 2. Create the run registry via `HookRegistry` + 3. Emit the status action — the address resolved against + `declared_actions` — via `emit_hook_event`: the context view of one + receiving tool is a `StatusRegistry` over the axis, qualified by + the tool identity — at most one `StatusRegistry` per tool identity, + all hooks of the tool share it; a hook registers its statuses + through the delivered registry + 4. Collect the registries of the subscribed tools in enumeration order + 5. Resolve the anchors of each surviving entry against the list + assembled by the moment the entry is processed — the built-in axis + plus the entries of the earlier tools and the earlier entries of + the current one; an anchor naming anything else, or an invalid + placement range, skips the registration with a warning in the log + 6. Assemble and return the scale + + Requirements: + - The emission assembles the registry on first use — the single build + of the run + - The scale assembles on the caller's demand at most once per run — + every command that renders statuses calls it before any output and + before any state change + - A failing or rejected registration never cancels the others + - Entries sharing an anchor form one continuous block in registration + order: an after-anchored entry lands at the end of its anchor's + block, a before-anchored entry right in front of its anchor, and + both anchors given define a range the entry must fit into + + Constraints: + - Do not enumerate the installed tool packages and do not import their + facades — the platform carries the tool packages + - Do not cache the scale across command runs + +--- + +Author: Goga +CreatedAt: 29/08/26 +Description: | + Owner of the topic status scale — the built-in artifact axis, tool-status + registration, and maximal-status computation. diff --git a/goga/history/statuses/__init__.py b/goga/history/statuses/__init__.py new file mode 100644 index 00000000..20659703 --- /dev/null +++ b/goga/history/statuses/__init__.py @@ -0,0 +1,18 @@ +"""Status scale cell — the owner of the topic status scale. + +The built-in artifact axis, the registration of tool statuses through the +status action of the hooks platform, and the computation of a topic's +maximal present statuses. Pure scale logic — no filesystem probing of topic +directories, no git access, no CLI, no output rendering. +""" + +from .assembly import assemble_status_scale +from .registry import StatusRegistry +from .scale import Stage, StatusScale + +__all__: list[str] = [ + "Stage", + "StatusRegistry", + "StatusScale", + "assemble_status_scale", +] diff --git a/goga/history/statuses/assembly.py b/goga/history/statuses/assembly.py new file mode 100644 index 00000000..d258a68e --- /dev/null +++ b/goga/history/statuses/assembly.py @@ -0,0 +1,136 @@ +"""The scale assembly routine of the statuses cell. + +The routine declared in the cell CODEMANIFEST with ``location: assembly.py``: +the full status scale — the built-in axis extended by every tool subscribed +to the status action of the hooks platform. The cell emits the action and +places the registrations delivered through it; the tool packages are carried +by the platform. The assembly runs at every command start that needs the +scale; the scale is never cached across runs. A broken package import is the +only fatal case — it surfaces through the emission. +""" + +from __future__ import annotations + +import logging + +from ...hooks import HookRegistry, emit_hook_event +from .registry import StatusRegistry +from .scale import Stage, StatusScale + +logger = logging.getLogger(__name__) + +_BUILTIN_AXIS: list[Stage] = [ + Stage(name="empty", filepath=""), + Stage(name="todo", filepath="todo.md"), + Stage(name="defined", filepath="prd.md"), + Stage(name="discovered", filepath="adr.md"), + Stage(name="backlog", filepath="task.md"), + Stage(name="designed", filepath="arch.md"), + Stage(name="specified", filepath="design.md"), + Stage(name="planned", filepath="plan.md"), + Stage(name="done", filepath="completed/plan.md"), +] + +_ACTION_DOMAIN = "statuses" +_ACTION_NAME = "register_statuses" + + +def assemble_status_scale() -> StatusScale: + """Assemble the full status scale — the built-in axis extended by every subscribed tool. + + Returns: + The assembled scale. + + Algorithm: + 1. Build the built-in axis of nine entries + 2. Create the run registry via ``HookRegistry`` + 3. Emit the status action through the platform: the context view of + one receiving tool is a ``StatusRegistry`` over the axis, + qualified by the tool identity — at most one registry per tool + identity, all hooks of the tool share it + 4. Collect the delivered registries in enumeration order + 5. Resolve the anchors of each surviving entry against the list + assembled by the moment the entry is processed — the built-in + axis plus the entries of the earlier tools and the earlier + entries of the current one; an unresolvable anchor or an invalid + range skips the registration with a warning in the log + 6. Assemble and return the scale + + Requirements: + The scale assembles from the surviving registrations alone — one + broken registration never cancels the rest. The emission performs + the single build of the run; a broken package import surfacing + through it is the only fatal case. + + Constraints: + Do not enumerate the installed tool packages and do not import their + facades — the platform carries the tool packages. Do not cache the + scale across command runs. + + Placement follows the anchors of each surviving entry, resolved against + the list assembled by the moment the entry is processed. Entries sharing + an anchor form one continuous block in registration order: an + ``after``-anchored entry lands at the end of its anchor's block, a + ``before``-anchored entry right in front of its anchor, and both anchors + given define a range the entry must fit into. + """ + registry = HookRegistry() + registries: dict[str, StatusRegistry] = {} + + def context_for(tool: str) -> StatusRegistry: + """Build the context view of one receiving tool — at most one registry per tool identity.""" + if tool not in registries: + registries[tool] = StatusRegistry(builtin_stages=list(_BUILTIN_AXIS), tool_prefix=tool) + + return registries[tool] + + emit_hook_event(registry, _ACTION_DOMAIN, _ACTION_NAME, context_for) + + stages = list(_BUILTIN_AXIS) + + for status_registry in registries.values(): + for entry in status_registry.stages[len(_BUILTIN_AXIS) :]: + try: + index = _placement_index(stages, entry) + except ValueError as exc: + logger.warning("skipping status registration %s: %s", entry.name, exc) + continue + stages.insert(index, entry) + + return StatusScale(stages=stages) + + +def _placement_index(stages: list[Stage], entry: Stage) -> int: + """Resolve the anchors of one accepted entry to an insertion index. + + Args: + stages: The list assembled by the moment the entry is processed. + entry: The accepted tool entry to place. + + Returns: + The index the entry is inserted at. + + Raises: + ValueError: An anchor names no entry of the list, or the two + anchors define an invalid range. + """ + positions = {stage.name: index for index, stage in enumerate(stages)} + after = entry.after + before = entry.before + + if after is not None and after not in positions: + raise ValueError(f"status entry {entry.name!r}: unknown after anchor {after!r}") + if before is not None and before not in positions: + raise ValueError(f"status entry {entry.name!r}: unknown before anchor {before!r}") + if after is None: + return positions[before] + if before is None: + # The end of the anchor's block — every entry already inserted + # with the same ``after`` anchor sits between the anchor and this + # index, keeping the block in registration order. + block = sum(1 for stage in stages if stage.after == after) + return positions[after] + 1 + block + if not positions[after] < positions[before]: + raise ValueError(f"status entry {entry.name!r}: anchor range {after!r}..{before!r} is invalid") + + return positions[before] diff --git a/goga/history/statuses/registry.py b/goga/history/statuses/registry.py new file mode 100644 index 00000000..dea68fb9 --- /dev/null +++ b/goga/history/statuses/registry.py @@ -0,0 +1,96 @@ +"""The tool-status registration surface of the statuses cell. + +The entity declared in the cell CODEMANIFEST with ``location: registry.py``: +the controlled registration surface handed to a tool package — the only way +a tool status enters the scale. Pure registration logic: names are qualified +and the content is validated here, while anchor resolution and placement +stay with the scale assembly — a tool may anchor to an entry registered by +another tool. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .scale import Stage + + +@dataclass(kw_only=True) +class StatusRegistry: + """The controlled registration surface handed to a tool package. + + The only way a tool status enters the scale. One registry instance is + the context view delivered to the hook of one subscribed tool; the hook + registers through it and nothing else. Registration is add-only — a + built-in entry is never modified, removed, or re-anchored. + + Attributes: + builtin_stages: The immutable built-in axis the registry extends. + tool_prefix: The qualifier applied to every name registered through + this registry — the tool identity of the receiving hook. + + Requirements: + Registration is add-only — a built-in entry is never modified, + removed, or re-anchored. + """ + + builtin_stages: list[Stage] + tool_prefix: str + _entries: list[Stage] = field(default_factory=list, init=False, repr=False) + + @property + def stages(self) -> list[Stage]: + """The built-in axis plus every accepted tool entry. + + A copy is issued every time — mutating the returned list never + reaches the registry content. + """ + return [*self.builtin_stages, *self._entries] + + def register( + self, + name: str, + filepath: str, + before: str | None = None, + after: str | None = None, + ) -> None: + """Register one tool status. + + Args: + name: The status name as the tool defines it — stored qualified + as ``<tool_prefix>.<name>``. + filepath: The artifact path relative to the topic directory. + before: Optional anchor — the qualified name of an entry this one + precedes. + after: Optional anchor — the qualified name of an entry this one + follows. + + Raises: + ValueError: A structural violation — the message names the entry. + + Algorithm: + 1. Qualify ``name`` with the registry's tool prefix + 2. Validate the content: a non-empty name, a non-empty + ``filepath``, and at least one anchor present + 3. Append the entry to the registry content + + Requirements: + A structural violation raises a clean registration error naming + the entry. Both anchors given define a placement range; anchor + resolution and range validity are decided at scale assembly. + + Constraints: + Do not resolve anchors here — a tool may anchor to an entry + registered by another tool. Do not modify built-in entries. + """ + qualified = f"{self.tool_prefix}.{name}" + + if not isinstance(name, str) or not name: + raise ValueError(f"status entry {qualified!r}: name must be a non-empty string") + if not isinstance(filepath, str) or not filepath: + raise ValueError(f"status entry {qualified!r}: filepath must be a non-empty string") + if before is None and after is None: + raise ValueError(f"status entry {qualified!r}: at least one anchor is required") + if any(entry.name == qualified for entry in self._entries): + raise ValueError(f"status entry {qualified!r}: already registered in this registry") + self._entries.append(Stage(name=qualified, filepath=filepath, before=before, after=after)) diff --git a/goga/history/statuses/scale.py b/goga/history/statuses/scale.py new file mode 100644 index 00000000..ae80db08 --- /dev/null +++ b/goga/history/statuses/scale.py @@ -0,0 +1,168 @@ +"""The status scale value model of the statuses cell. + +The entities declared in the cell CODEMANIFEST with ``location: scale.py``: +one entry of the scale — a named position anchored to the artifact that +marks it — and the assembled partially ordered scale, the single source of +scale order and maximal-status computation. Pure scale logic: presence is +decided by the caller's ``paths`` input alone — no filesystem probing, no +git, no CLI. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from itertools import pairwise + + +@dataclass(frozen=True, kw_only=True) +class Stage: + """One entry of the scale — a named position anchored to the artifact that marks it. + + Attributes: + name: The qualified name — bare for built-in entries, ``<tool>.<name>`` + for tool entries. + filepath: The artifact path relative to the topic directory; nested + paths allowed. The ``empty`` entry carries the empty string and is + never marked present. + before: The qualified name of the entry this one precedes; ``None`` + for built-in entries. + after: The qualified name of the entry this one follows; ``None`` for + built-in entries. + + Requirements: + A tool entry carries at least one anchor; a built-in entry carries + none — its position is the axis order. + """ + + name: str + filepath: str + before: str | None = None + after: str | None = None + + +@dataclass(kw_only=True) +class StatusScale: + """The assembled partially ordered scale of topic statuses. + + The single source of scale order and maximal-status computation. + + Attributes: + stages: The scale content in scale order. + + Requirements: + The built-in axis is ordered empty, todo, defined, discovered, + backlog, designed, specified, planned, done by the artifacts + todo.md, prd.md, adr.md, task.md, arch.md, design.md, plan.md, + completed/plan.md; a tool status never reorders or replaces a + built-in one. + """ + + stages: list[Stage] + + def maximal_present(self, paths: list[str]) -> list[str]: + """Compute the maximal present statuses of one topic. + + Args: + paths: The artifact paths present in a topic directory, relative + to it. + + Returns: + The qualified names of the maximal present statuses, in scale + order. A topic with no artifact present yields the single + built-in name ``empty``. + + Algorithm: + 1. Mark every scale entry whose artifact path is present + 2. Drop every marked entry strictly below another marked entry + 3. Return the names of the surviving entries in scale order + + Requirements: + Every maximal entry is returned — a present entry outranked by + no other present entry stays visible. + + Constraints: + Do not probe the filesystem — presence is decided by the + caller's ``paths`` input alone. + + ``Strictly below'' follows the scale's partial order: the built-in + axis (the entries carrying no anchor) is a chain in list order, and + every anchor adds one edge — an entry anchored ``after`` another is + above it, one anchored ``before`` another is below it. Two tool + entries sharing an anchor are incomparable, so both stay maximal + when both artifacts are present; ``in scale order`` is the assembled + list order. + """ + present = set(paths) + marked = [stage for stage in self.stages if stage.filepath and stage.filepath in present] + + if not marked: + return ["empty"] + + above = self._strictly_above() + marked_names = {stage.name for stage in marked} + maximal = [stage for stage in marked if not above[stage.name] & marked_names] + + return [stage.name for stage in maximal] + + def resolve_status(self, name: str) -> Stage: + """Resolve a qualified status name for filter validation. + + Args: + name: A status name as entered by a consumer — a built-in name + or a qualified tool name. + + Returns: + The scale entry carrying the name. + + Raises: + ValueError: The name matches no entry of the scale. + + Algorithm: + 1. Find the scale entry whose qualified name equals ``name`` + exactly + 2. An unknown name raises a clean error + + Constraints: + Do not fuzzy-match or fall back — the exact qualified name is + the contract. + """ + for stage in self.stages: + if stage.name == name: + return stage + raise ValueError(f"unknown status name: {name!r}") + + def _strictly_above(self) -> dict[str, set[str]]: + """Map every qualified name to the names of the entries strictly above it. + + The transitive closure of the scale's ``below -> above`` edges: the + anchor-free built-in axis chained in list order, plus one edge per + anchor — ``after=A`` puts the entry above ``A``, ``before=B`` puts it + below ``B``. An anchor naming no entry of the scale adds no edge. + """ + above: dict[str, set[str]] = {stage.name: set() for stage in self.stages} + axis = [stage for stage in self.stages if stage.before is None and stage.after is None] + + for below, upper in pairwise(axis): + above[below.name].add(upper.name) + + for stage in self.stages: + if stage.after is not None and stage.after in above: + above[stage.after].add(stage.name) + if stage.before is not None and stage.before in above: + above[stage.name].add(stage.before) + # Close transitively by iterating to a fixed point — the scale is + # small, and the loop stays safe even on a cyclic hand-built input. + names = list(above) + changed = True + + while changed: + changed = False + for name in names: + uppers = above[name] + inherited = set().union(*(above[upper] for upper in uppers)) if uppers else set() + missing = inherited - uppers + if missing: + above[name] = uppers | missing + changed = True + + return above diff --git a/goga/history/tree.py b/goga/history/tree.py new file mode 100644 index 00000000..e12c6376 --- /dev/null +++ b/goga/history/tree.py @@ -0,0 +1,73 @@ +"""History tree inventory for the history domain. + +The entities declared in the cell CODEMANIFEST with ``location: tree.py``: +the per-year record of the tree listing and the tree collector — the full +tree or one selected year. The collector is read-only and carries names +only — statuses belong to the status module, filtering and rendering to the +consumer. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .paths import _history_root + +_YEAR_NAME_LENGTH = 4 + + +@dataclass(frozen=True, kw_only=True) +class HistoryYear: + """One year of the history tree paired with its topic names. + + Attributes: + year: The year as four digits — the directory name of the year. + topics: The topic slugs found under the year, sorted alphabetically. + """ + + year: str + topics: list[str] + + +def collect_history_tree(year: str | None = None) -> list[HistoryYear]: + """Collect the history tree — every year with its topics, or the one named year alone. + + A year directory is a directory named with exactly four ASCII digits — + anything else in the history root is ignored (the ASCII filter matters: + some non-ASCII digit strings still satisfy ``str.isdigit()``). Only + directories count as topics; stray files are ignored on both levels. + + Args: + year: Optional year as four digits; ``None`` and the empty string + mean no year selection — the full tree; a year missing from the + tree yields an empty list — not an error. + + Returns: + One ``HistoryYear`` per selected year — years sorted ascending, + topics within a year sorted alphabetically. An absent history root + yields an empty list, not an error. Read-only — nothing is created, + and no status is computed: the tree carries topic names only. + """ + root = _history_root() + + if not root.is_dir(): + return [] + + selected = year or None + years = sorted( + path.name + for path in root.iterdir() + if path.is_dir() + and len(path.name) == _YEAR_NAME_LENGTH + and path.name.isascii() + and path.name.isdigit() + and (selected is None or path.name == selected) + ) + + return [ + HistoryYear( + year=year_name, + topics=sorted(entry.name for entry in (root / year_name).iterdir() if entry.is_dir()), + ) + for year_name in years + ] diff --git a/goga/hooks/.usages/declaring-actions.md b/goga/hooks/.usages/declaring-actions.md new file mode 100644 index 00000000..50cd681e --- /dev/null +++ b/goga/hooks/.usages/declaring-actions.md @@ -0,0 +1,39 @@ +# hooks — opening a domain action + +How a goga domain maintainer opens an extension point for installed tool +packages. For domain maintainers inside goga. + +## Declare the action + +Add one record to the action catalog — the domain, the action name, and +the error class: + +- `soft` — a failing hook is skipped with a log warning; the command + continues. +- `hard` — the first failing hook stops the command with a clean error. + +## Define the context contract + +The action's context is your own object. Its members are the single +channel tools have into your domain — publish the surface you want tools +to call, and state in your contract what each member does and when the +event fires. When tools write through the context, give each receiving +tool its own view of it — the emission builds the view per tool. + +## Emit at the checkpoint + +```python +emit_hook_event(HookRegistry(), "<domain>", "<action>", context_for=build_view) +``` + +- The emission assembles the registry on first use — the single build of + the run; there is no separate build step. +- `context_for` takes a tool identity and returns the object that tool's + hooks receive — return the same instance to share, a scoped view to + isolate. + +## What the platform carries for you + +Package enumeration, facade imports, the callback call, envelope +validation, delivery, injection, error classes, and diagnostics — your +domain declares the action and emits it; nothing else. diff --git a/goga/hooks/.usages/registering-hooks.md b/goga/hooks/.usages/registering-hooks.md new file mode 100644 index 00000000..b4e1d966 --- /dev/null +++ b/goga/hooks/.usages/registering-hooks.md @@ -0,0 +1,65 @@ +# hooks — registering domain hooks + +How a `goga_tool_*` package subscribes its hooks to the actions of goga +domains. For tool package authors; no goga code changes are needed. + +A package may define three facade callbacks, separated by nature: `main` +(the CLI call of the tool), `install` (the post-install lifecycle hook), +and `register_hooks` (the domain extension registration described here). + +## The callback + +```python +# inside the goga_tool_<tool> package +def register_hooks(hooks): + hooks.subscribe("statuses", "register_statuses", "my_status", register_my_status) +``` + +- `domain` — the semantic owner of the action; with the action name it is + the address of the subscription. +- `action` — the action name within the domain. +- `name` — the hook name, unique per tool per address. +- `hook` — the callable executed when the action fires. + +The tool identity is assigned by goga from the package name — a package +never names itself, and identical hook names of different tools never +collide. + +## The hook signature + +A hook receives values only for the parameters it declares by the fixed +offered names: + +```python +def register_my_status(self, context): + context.register("published", "mkdocs/published.md", after="planned") +``` + +- `self` — the isolated context of your tool; one instance links all its + hook invocations of a run; freely mutable by your tool. +- `context` — the delivered object of the action; read attributes and + call methods freely, attribute assignment is blocked. + +The declaration order does not matter; names you did not declare receive +nothing. + +## When hooks run + +goga calls `register_hooks` when a command first reaches a hook +checkpoint of the run — or when you inspect the registry with +`goga hooks`. Commands that use no hooks never call it. Registration is +never cached — package edits apply from the next run, without reinstall. + +## Failure behavior + +- A wrong address, an empty name, or a repeated name on the same address — + a log warning naming your tool, the action, and the reason; the + registration is skipped, the rest apply. +- A crashing callback — a warning; the registrations made before the + crash survive. +- A failing hook — a warning, the hook is skipped, the sequence continues + (soft action); or a command error stopping at the first failure (hard + action). The action's error class is fixed by the domain in the action + catalog. +- A broken package import is the only fatal case: a clean error naming + the package. diff --git a/goga/hooks/CODEMANIFEST b/goga/hooks/CODEMANIFEST new file mode 100644 index 00000000..17e467ba --- /dev/null +++ b/goga/hooks/CODEMANIFEST @@ -0,0 +1,45 @@ +Imports: + - Types: + - declared_actions + From: goga/hooks/catalog + - Types: + - HookRegistry + - ToolHooks + From: goga/hooks/registry + - Types: + - emit_hook_event + From: goga/hooks/dispatch + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell is the facade of the hooks platform — the extension surface of + the goga domains for installed tool packages. It exposes the platform + API: the declared action catalog, the run registry with its inspection + view, and the emission of an action at a domain checkpoint — the emission + assembles the registry on first use. Consumers address the platform + through this facade only. Apply the `convention` practice for the code + style and intra-package imports. Use relative imports. + +--- + +->declared_actions: {} +->HookRegistry: {} +->ToolHooks: {} +->emit_hook_event: {} + +--- + +Author: Goga +CreatedAt: 31/08/26 +Description: | + Facade of the hooks platform — the extension surface of the goga domains + for installed tool packages. diff --git a/goga/hooks/__init__.py b/goga/hooks/__init__.py new file mode 100644 index 00000000..5539eb40 --- /dev/null +++ b/goga/hooks/__init__.py @@ -0,0 +1,20 @@ +"""Hooks platform facade — the extension surface of the goga domains. + +The single consumer entry point of the platform for installed tool packages +and for the domains: it re-exports the declared action catalog, the run +registry with its per-tool inspection view, and the emission of an action at +a domain checkpoint — the emission assembles the registry on first use. The +facade declares no type of its own. Importing the package imports no tool +package and enumerates nothing. +""" + +from .catalog import declared_actions +from .dispatch import emit_hook_event +from .registry import HookRegistry, ToolHooks + +__all__: list[str] = [ + "HookRegistry", + "ToolHooks", + "declared_actions", + "emit_hook_event", +] diff --git a/goga/hooks/catalog/CODEMANIFEST b/goga/hooks/catalog/CODEMANIFEST new file mode 100644 index 00000000..aebdf3dd --- /dev/null +++ b/goga/hooks/catalog/CODEMANIFEST @@ -0,0 +1,81 @@ +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the action catalog — the map of subscription addresses of + the domains, each carrying its error class. The catalog is the single + source of known addresses: a registration envelope is validated against + it and an emitted address resolves through it. The catalog is data only — + no package enumeration, no subscription state, no delivery. A domain + opening an action extends the catalog additively; published records are + never rewritten. The error class of a record states how a failing hook of + the action is treated — soft failures are skipped with a warning, hard + failures stop the command. Use relative imports. + +--- + +"Action(domain: str, name: str, error_class: str)": + location: catalog.py + annotations: | + One catalog record — a named subscription address with its error class. + + `domain`: the semantic owner of the action — the domain whose + checkpoint emits the event + `name`: the action name within its domain + `error_class`: the failure treatment of the action's hooks — soft or hard + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - `domain` and `name` are non-empty; the pair is unique in the catalog + - `error_class` is exactly soft or hard + - The record carries no behavior — the context form and the event + moment belong to the contract of the owner domain + properties: + "domain -> str": | + The semantic owner domain of the action. + "name -> str": | + The action name within its domain. + "error_class -> str": | + The failure treatment of the action's hooks — soft or hard. + +"declared_actions() -> actions: list[Action]": + location: catalog.py + annotations: | + The declared action catalog — the single source of known addresses. + + `actions`: every declared record, ordered by domain then by name + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Return the catalog records ordered by domain, then by name + + Requirements: + - Deterministic — the same records in the same order on every call + - Complete — no filtering and no partial views + - The catalog carries the statuses action — the record + domain="statuses", name="register_statuses", error_class="soft": a + failing hook of the action is skipped with a warning and the command + continues + + Constraints: + - Do not derive records from installed packages or imports — the + catalog is maintained data, not discovery + +--- + +Author: Goga +CreatedAt: 31/08/26 +Description: | + Owner of the action catalog — the subscription addresses of the domains + with their error classes. diff --git a/goga/hooks/catalog/__init__.py b/goga/hooks/catalog/__init__.py new file mode 100644 index 00000000..17529c3f --- /dev/null +++ b/goga/hooks/catalog/__init__.py @@ -0,0 +1,14 @@ +"""Action catalog cell — the map of subscription addresses of the domains. + +The owner of the action catalog: every known address with its error class. +The catalog is data only — a registration envelope is validated against it +and an emitted address resolves through it. Importing the package imports +no packages and enumerates nothing. +""" + +from .catalog import Action, declared_actions + +__all__: list[str] = [ + "Action", + "declared_actions", +] diff --git a/goga/hooks/catalog/catalog.py b/goga/hooks/catalog/catalog.py new file mode 100644 index 00000000..0f498cfd --- /dev/null +++ b/goga/hooks/catalog/catalog.py @@ -0,0 +1,56 @@ +"""The action catalog of the hooks platform. + +The entities declared in the cell CODEMANIFEST with ``location: catalog.py``: +the catalog record ``Action`` and the routine ``declared_actions``. The +catalog is the single source of known subscription addresses of the domains — +supported data only. No package enumeration, no subscription state, no +delivery; a domain opening an action extends the catalog additively and +published records are never rewritten. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, kw_only=True) +class Action: + """One catalog record — a named subscription address with its error class. + + The record carries no behavior: the context form and the event moment + belong to the contract of the owner domain. + + Attributes: + domain: The semantic owner domain of the action — the domain whose + checkpoint emits the event. + name: The action name within its domain. + error_class: The failure treatment of the action's hooks — soft or + hard. + + Requirements: + ``domain`` and ``name`` are non-empty; the pair is unique in the + catalog; ``error_class`` is exactly soft or hard. + """ + + domain: str + name: str + error_class: str + + +_DECLARED_ACTIONS: list[Action] = [ # supported data, not discovery + Action(domain="statuses", name="register_statuses", error_class="soft"), +] + + +def declared_actions() -> list[Action]: + """Return the declared action catalog — the single source of known addresses. + + Every declared record, ordered by domain then by name. A new list on + every call — the catalog constant is never mutated and its records are + frozen. + + Returns: + Every declared record, complete and unfiltered, ordered by domain + then by name. + """ + return sorted(_DECLARED_ACTIONS, key=lambda action: (action.domain, action.name)) diff --git a/goga/hooks/dispatch/CODEMANIFEST b/goga/hooks/dispatch/CODEMANIFEST new file mode 100644 index 00000000..d0ac5402 --- /dev/null +++ b/goga/hooks/dispatch/CODEMANIFEST @@ -0,0 +1,141 @@ +Imports: + - Types: + - declared_actions + From: goga/hooks/catalog + - Types: + - HookRegistry + - Subscription + - ToolContext + From: goga/hooks/registry + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the delivery of events: the transparent mediation of the + emitted domain object, the injection of hook arguments by fixed declared + names, and the emission of an action to its subscribed hooks under the + action's error class. Delivery is fire-and-forget — the single channel a + tool has towards a domain is calling members of the delivered object; + nothing is collected after the event. Every diagnostic names the tool, + the action, and the reason. Use relative imports. + +--- + +"wrap_context(target: object) -> proxy: object": + location: delivery.py + annotations: | + Wrap an emitted domain object for delivery to a hook. + + `target`: the object the emitting checkpoint hands over + `proxy`: the delivery view of `target` + + Apply the `convention` practice for docstring style and intra-package + imports. + + Requirements: + - Attribute reads pass through — the proxy resolves them on `target` + - A property member of `target` reads through like any attribute + - Method calls pass through — a called member runs on `target` with the + arguments the caller gave + - Attribute assignment is blocked — a clean error, `target` stays + untouched + - The proxy exposes no reference to `target` + + Constraints: + - Do not copy or reinterpret the object — mediation is transparent for + reads and calls + - Do not mediate special methods — the proxy intercepts plain attribute + access and calls only; dunder lookup on the proxy itself follows the + language default and never reaches `target` + +"build_hook_arguments(hook: Callable, context: object, self_context: ToolContext) -> args: dict[str, object]": + location: delivery.py + annotations: | + Project a hook signature against the offered injection names. + + `hook`: the registered callable + `context`: the delivery view of the emitted object + `self_context`: the isolated context of the hook's own tool + `args`: the keyword arguments to call `hook` with + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Examine the signature of `hook` and enumerate its keyword-capable + parameters + 2. A declared parameter named `context` receives `context`; a declared + parameter named self receives `self_context` + 3. Parameters with other names receive nothing — the offered-name set is + the single source of the opt-in + 4. No declared offered name yields an empty mapping + + Requirements: + - The declaration order does not matter — values land by name + - The mapping never carries a value the hook did not declare + + Constraints: + - Do not call `hook` — the call belongs to the emission + +"emit_hook_event(registry: HookRegistry, domain: str, action: str, context_for: Callable)": + location: emit.py + annotations: | + Emit an action of a domain checkpoint to its subscribed hooks. + + `registry`: the run registry — assembled on first use + `domain`: the emitting domain — the semantic owner of the action + `action`: the action name within the domain + `context_for`: builds the context view of one receiving tool — takes the + tool identity, returns the object that tool's hooks receive + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Assemble the registry via its build_once method — the first + emission of a run performs the single build + 2. Resolve `domain` and `action` against `declared_actions` — an unknown + address is a clean error of the emitting side + 3. Take the `Subscription` entries of the address from `registry`, in + enumeration order + 4. For each subscription, in enumeration order: resolve the receiving + tool's context view via `context_for` — one view per distinct tool + of the address, built at the tool's first subscription and reused + by its remaining subscriptions; wrap it via `wrap_context`, project + the call arguments via `build_hook_arguments` with the tool's own + context from the registry, and call the hook + 5. Treat a failure per the action's error class: soft — a warning in + the log naming the tool, the action, and the reason, the hook is + skipped, the sequence continues; hard — a clean error naming the tool + and the reason, the sequence stops at the first failure + 6. An address without subscriptions emits nothing + + Requirements: + - Fire-and-forget — nothing is returned and nothing is collected after + the event + - Each tool receives the proxy of its own context view — tools never + share a mutable object + - An exception leaving a hook or the projection is a hook failure and + nothing else + - A failure of `context_for` is a clean error of the emitting side — + never treated as a hook failure + + Constraints: + - Do not rebuild an assembled registry — the build runs once per run + - Do not deliver a hook any value it did not declare + +--- + +Author: Goga +CreatedAt: 31/08/26 +Description: | + Owner of the event delivery — the transparent context mediation, the + fixed-name injection, and the emission under the action's error class. diff --git a/goga/hooks/dispatch/__init__.py b/goga/hooks/dispatch/__init__.py new file mode 100644 index 00000000..4fded35d --- /dev/null +++ b/goga/hooks/dispatch/__init__.py @@ -0,0 +1,17 @@ +"""Event delivery cell — the mediation and the emission of the hooks platform. + +The owner of the transparent delivery view of an emitted domain object, the +fixed-name injection of hook arguments, and the emission of an action to its +subscribed hooks under the action's error class. Importing the package +enumerates nothing and builds no registry — the single build of a run happens +on the first emission. +""" + +from .delivery import build_hook_arguments, wrap_context +from .emit import emit_hook_event + +__all__: list[str] = [ + "build_hook_arguments", + "emit_hook_event", + "wrap_context", +] diff --git a/goga/hooks/dispatch/delivery.py b/goga/hooks/dispatch/delivery.py new file mode 100644 index 00000000..0158eff9 --- /dev/null +++ b/goga/hooks/dispatch/delivery.py @@ -0,0 +1,101 @@ +"""The context mediation of the hooks platform. + +The entities declared in the cell CODEMANIFEST with ``location: delivery.py``: +the delivery view ``wrap_context`` and the fixed-name injection +``build_hook_arguments``. Mediation is transparent for reads and calls — a +hook works with the emitted object as if it held it — and closed for writes: +the delivered context is read-only, and the only state a hook may write is +its own tool context, delivered separately under the declared name ``self``. +Nothing here calls a hook; the call belongs to the emission. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable + +from ..registry import ToolContext + +_KEYWORD_CAPABLE = frozenset( + { + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + } +) +"""The parameter kinds a call may fill by name — the injection surface.""" + + +def wrap_context(target: object) -> object: + """Wrap an emitted domain object for delivery to a hook. + + The proxy resolves every attribute read on ``target`` — plain + attributes, properties, bound methods — so a hook sees the emitted + object itself and nothing else. Writes are closed: attribute assignment + and deletion raise a clean error and ``target`` stays untouched. + + The proxy hides ``target`` completely. The proxy class is created in the + closure of this call — a fresh class per delivery, so no type to + introspect — its instances carry no ``__dict__`` (``__slots__ = ()``), + and a dunder lookup on the proxy follows the language default instead of + reaching ``target``. + + Args: + target: The object the emitting checkpoint hands over. + + Returns: + The delivery view of ``target``. + """ + + class _DeliveryProxy: + """The delivery view of one emitted object — reads pass, writes do not.""" + + __slots__ = () # no instance dict — nowhere to keep or find the target + + def __getattr__(self, name: str) -> object: + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) # a dunder: the language default, never target + + return getattr(target, name) + + def __setattr__(self, name: str, value: object) -> None: + raise AttributeError("the delivered context is read-only: attribute assignment is blocked") + + def __delattr__(self, name: str) -> None: + raise AttributeError("the delivered context is read-only: attribute deletion is blocked") + + return _DeliveryProxy() + + +def build_hook_arguments( + hook: Callable[..., object], + context: object, + self_context: ToolContext, +) -> dict[str, object]: + """Project a hook signature against the offered injection names. + + The offered-name set is the single source of the opt-in: a declared + ``context`` parameter receives the delivery view of the emitted object, a + declared ``self`` parameter receives the isolated context of the hook's + own tool, and any other declared name receives nothing. Only + keyword-capable parameters participate — a positional-only ``context`` + declares no injection. The declaration order does not matter: values + land by name. The hook is not called here; the call belongs to the + emission. + + Args: + hook: The registered callable. + context: The delivery view of the emitted object. + self_context: The isolated context of the hook's own tool. + + Returns: + The keyword arguments to call ``hook`` with — never a value the hook + did not declare. + """ + offered = {"context": context, "self": self_context} + arguments: dict[str, object] = {} + + for parameter in inspect.signature(hook).parameters.values(): + if parameter.kind in _KEYWORD_CAPABLE and parameter.name in offered: + arguments[parameter.name] = offered[parameter.name] + + return arguments diff --git a/goga/hooks/dispatch/emit.py b/goga/hooks/dispatch/emit.py new file mode 100644 index 00000000..4a9aee23 --- /dev/null +++ b/goga/hooks/dispatch/emit.py @@ -0,0 +1,108 @@ +"""The emission of the hooks platform. + +The entity declared in the cell CODEMANIFEST with ``location: emit.py``: +``emit_hook_event`` — the emission of an action of a domain checkpoint to its +subscribed hooks under the action's error class. This is the only point where +the registry is assembled: the first emission of a run performs the single +build, and no emission of the same run rebuilds it. Delivery is +fire-and-forget — nothing is returned and nothing is collected after the +event; the single channel a tool has towards the emitting domain is calling +members of the delivered object. Every diagnostic names the tool, the action, +and the reason. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable + +from ..catalog import declared_actions +from ..registry import HookRegistry +from .delivery import build_hook_arguments, wrap_context + +logger = logging.getLogger(__name__) + + +def emit_hook_event( + registry: HookRegistry, + domain: str, + action: str, + context_for: Callable[[str], object], +) -> None: + """Emit an action of a domain checkpoint to its subscribed hooks. + + Args: + registry: The run registry — assembled on first use, never rebuilt. + domain: The emitting domain — the semantic owner of the action. + action: The action name within its domain. + context_for: Builds the context view of one receiving tool — takes the + tool identity, returns the object that tool's hooks receive. + + Algorithm: + 1. Assemble the registry via ``build_once`` — the first emission of a + run performs the single build + 2. Resolve ``domain`` and ``action`` against ``declared_actions`` — an + unknown address is a clean error of the emitting side + 3. Take the subscriptions of the address in enumeration order; for + each, build the receiving tool's context view via ``context_for`` + — one view per distinct tool of the address, built at the tool's + first subscription and reused by its remaining subscriptions + 4. Wrap the view via ``wrap_context``, project the call arguments via + ``build_hook_arguments`` with the tool's own context from the + registry, and call the hook + 5. Treat a failure per the action's error class: soft — a warning in + the log naming the tool, the action, and the reason, the hook is + skipped, the sequence continues; hard — a clean error naming the + tool and the reason, the sequence stops at the first failure + + Requirements: + An address without subscriptions emits nothing. A failure of + ``context_for`` is a clean error of the emitting side — never treated + as a hook failure — so it is raised outside the failure intercept + below, which covers the wrapping, the projection, and the call as one + interceptable space and catches ``Exception`` only: a + ``BaseException`` such as ``KeyboardInterrupt`` passes through. + + Raises: + ValueError: The address is not declared, or a hook of a hard action + failed — the message names the hook, the tool, and the reason. + """ + registry.build_once() + + record = next( + (entry for entry in declared_actions() if entry.domain == domain and entry.name == action), + None, + ) + if record is None: + raise ValueError(f"unknown hook action: {domain}.{action}") + + views: dict[str, object] = {} + + for subscription in registry.subscriptions_for(domain, action): + if subscription.tool not in views: + # Outside the intercept below: a crashing view builder is a clean + # error of the emitting side, never a hook failure. + views[subscription.tool] = context_for(subscription.tool) + + try: + view = wrap_context(views[subscription.tool]) + arguments = build_hook_arguments( + subscription.hook, + view, + registry.self_context(subscription.tool), + ) + subscription.hook(**arguments) + except Exception as exc: + if record.error_class == "hard": + raise ValueError( + f"hook {subscription.name} of tool {subscription.tool} failed on {domain}.{action}: {exc}" + ) from exc + + logger.warning( + "hook %s of tool %s failed on %s.%s: %s", + subscription.name, + subscription.tool, + domain, + action, + exc, + ) diff --git a/goga/hooks/registry/CODEMANIFEST b/goga/hooks/registry/CODEMANIFEST new file mode 100644 index 00000000..a8aa9499 --- /dev/null +++ b/goga/hooks/registry/CODEMANIFEST @@ -0,0 +1,154 @@ +Imports: + - Types: + - ToolPackage + - enumerate_tool_packages + - call_register_hooks + - HookRegistrar + - Subscription + - RejectedRegistration + From: goga/hooks/tools + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the run registry: the single assembly of one run, the + read side of the assembled subscriptions, the isolated per-tool + contexts, and the per-tool inspection view. The assembly happens once — + on the first build_once call — and every diagnostic names the tool and + the reason. Pure state and read logic — no package access and no + delivery; those belong to the other zones of the platform. Use relative + imports. + +--- + +"HookRegistry()": + location: state.py + annotations: | + The run registry — the assembled state of one run, built on first use. + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Created empty and cheap — no enumeration and no imports happen at + construction + properties: + "subscriptions -> list[Subscription]": | + Every accepted subscription after the single build, in enumeration + order. + "rejections -> list[RejectedRegistration]": | + Every rejected envelope after the single build, in enumeration order. + methods: + "build_once()": | + Assemble the registry of the run — the single build. + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. An already assembled registry does nothing — the build runs once + per object, every run uses a fresh registry + 2. Enumerate the installed tool packages — `ToolPackage` identities — + via `enumerate_tool_packages` + 3. For each package in order: create a registration surface + qualified by the package identity via `HookRegistrar` and run its + callback via `call_register_hooks` + 4. A package without the callback is skipped quietly + 5. An exception raised by a callback ends that callback's + registration: a warning in the log naming the tool and the reason, + the registrations made before the failure survive, the next + package is processed + 6. A broken package import is a clean error naming the package — the + single fatal case + 7. Collect the accepted `Subscription` entries and the + `RejectedRegistration` envelopes of every registrar into this + registry, in enumeration order + + Requirements: + - One tool's failure never cancels another tool's registrations + "subscriptions_for(domain: str, action: str) -> subscriptions: list[Subscription]": | + The subscriptions of one action address. + + `domain`: the owner domain of the action + `action`: the action name within the domain + `subscriptions`: the address's subscriptions, in enumeration order + + Algorithm: + 1. Keep the subscriptions whose domain and action match exactly + + Requirements: + - An address without subscriptions yields an empty list — not an error + "self_context(tool: str) -> context: ToolContext": | + The isolated runtime context of one tool. + + `tool`: the tool identity + `context`: the tool's own context instance + + Requirements: + - One instance per tool per run — repeated calls return the same + value, linking the tool's invocations + - Contexts of different tools never share state; a domain never + receives one + "by_tool() -> view: list[ToolHooks]": | + The per-tool inspection view of the registry. + + `view`: one entry per tool with a subscription or a rejection, + ordered alphabetically by tool + + Requirements: + - A tool with both accepted and rejected registrations carries both + - The view states the fact of registration — never the application + +"ToolContext(tool: str)": + location: state.py + annotations: | + The isolated runtime context of one tool — links the invocations of + its hooks within one run and stays invisible to the domains. + + `tool`: the environment-assigned tool identity of the owner + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - Freely mutable by its own tool — the only state a hook may write + without restriction, unlike a delivered domain context + properties: + "tool -> str": | + The tool identity of the owning tool. + +"ToolHooks(tool: str, subscriptions: list[Subscription], rejections: list[RejectedRegistration])": + location: state.py + annotations: | + The per-tool inspection entry — one tool with its registrations and + refusals. + + `tool`: the tool identity + `subscriptions`: the tool's accepted subscriptions + `rejections`: the tool's refused envelopes + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "tool -> str": | + The tool identity of the entry. + "subscriptions -> list[Subscription]": | + The tool's accepted subscriptions. + "rejections -> list[RejectedRegistration]": | + The tool's refused envelopes. + +--- + +Author: Goga +CreatedAt: 31/08/26 +Description: | + Owner of the run registry — the single assembly, the read side, the + per-tool contexts, and the inspection view. diff --git a/goga/hooks/registry/__init__.py b/goga/hooks/registry/__init__.py new file mode 100644 index 00000000..9603aa12 --- /dev/null +++ b/goga/hooks/registry/__init__.py @@ -0,0 +1,15 @@ +"""Run registry cell — the assembled state of one run. + +The owner of the single assembly, the read side of the assembled +subscriptions, the isolated per-tool contexts, and the per-tool inspection +view. Importing the package imports no tool package and enumerates nothing — +a registry is built once, on the first ``build_once`` call of its object. +""" + +from .state import HookRegistry, ToolContext, ToolHooks + +__all__: list[str] = [ + "HookRegistry", + "ToolContext", + "ToolHooks", +] diff --git a/goga/hooks/registry/state.py b/goga/hooks/registry/state.py new file mode 100644 index 00000000..c3087cc3 --- /dev/null +++ b/goga/hooks/registry/state.py @@ -0,0 +1,192 @@ +"""The run registry of the hooks platform. + +The entities declared in the cell CODEMANIFEST with ``location: state.py``: +the run registry ``HookRegistry``, the isolated runtime context +``ToolContext``, and the per-tool inspection entry ``ToolHooks``. The registry +is the state of one run: it assembles itself once — on the first +``build_once`` call — by walking the installed tool packages through the +tool-package access of the platform, and it offers the read side of what was +assembled. Pure state and read logic: the packages are reached through the +tools cell, the delivery belongs to the dispatch zone. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from ..tools import ( + HookRegistrar, + RejectedRegistration, + Subscription, + call_register_hooks, + enumerate_tool_packages, +) + +logger = logging.getLogger(__name__) + + +@dataclass(kw_only=True) +class HookRegistry: + """The run registry — the assembled state of one run, built on first use. + + Created empty and cheap: no package is enumerated and no module imported + at construction. The single build happens on the first ``build_once`` + call and never repeats on the same object — every run works over a fresh + registry, nothing is cached across runs. + """ + + _built: bool = field(init=False, default=False, repr=False) + _subscriptions: list[Subscription] = field(init=False, default_factory=list, repr=False) + _rejections: list[RejectedRegistration] = field(init=False, default_factory=list, repr=False) + _contexts: dict[str, ToolContext] = field(init=False, default_factory=dict, repr=False) + + @property + def subscriptions(self) -> list[Subscription]: + """Every accepted subscription, in enumeration order — a read copy.""" + return list(self._subscriptions) + + @property + def rejections(self) -> list[RejectedRegistration]: + """Every refused envelope, in enumeration order — a read copy.""" + return list(self._rejections) + + def build_once(self) -> None: + """Assemble the registry of the run — the single build. + + Algorithm: + 1. An already assembled registry does nothing — the flag is set + before the enumeration, so a nested or repeated build never + reads the environment twice + 2. Enumerate the installed tool packages via + ``enumerate_tool_packages`` + 3. For each package in order: create a ``HookRegistrar`` scoped + to the package identity and run its callback via + ``call_register_hooks`` + 4. An exception of a callback ends that callback's registration + only: a warning in the log naming the tool and the reason, the + registrations made before the failure survive, the next + package is processed + 5. A broken package import — the platform-wrapped ``ImportError`` + naming the package — is the single fatal case and leaves the + build as a clean error + 6. The accepted subscriptions and the refused envelopes of every + registrar, also of a crashed one, join this registry in + enumeration order + + Requirements: + One tool's failure never cancels another tool's registrations. + + Raises: + ImportError: A tool package exists but its facade fails to + import — the message names the package. + """ + if self._built: + return + + self._built = True + + for package in enumerate_tool_packages(): + registrar = HookRegistrar(tool=package.tool) + + try: + call_register_hooks(package, registrar) + + except ImportError as exc: + # The import wrapper of the platform names the broken package; + # an ImportError raised by the tool callback itself is a crash + # like any other and is told apart by that message prefix. + if str(exc).startswith(f"package {package.facade} failed to import:"): + raise + + logger.warning("skipping hook registration of tool %s: %s", package.tool, exc) + + except Exception as exc: + logger.warning("skipping hook registration of tool %s: %s", package.tool, exc) + + self._subscriptions.extend(registrar.subscriptions) + self._rejections.extend(registrar.rejections) + + def subscriptions_for(self, domain: str, action: str) -> list[Subscription]: + """Return the subscriptions of one action address. + + Args: + domain: The owner domain of the action. + action: The action name within the domain. + + Returns: + The subscriptions of the address, in enumeration order — an + empty list when the address carries none, never an error. + """ + return [ + subscription + for subscription in self._subscriptions + if subscription.domain == domain and subscription.action == action + ] + + def self_context(self, tool: str) -> ToolContext: + """Return the isolated runtime context of one tool. + + Args: + tool: The tool identity of the owner. + + Returns: + The tool's own context — one instance per tool per run, so the + invocations of its hooks share their state. + """ + if tool not in self._contexts: + self._contexts[tool] = ToolContext(tool=tool) + + return self._contexts[tool] + + def by_tool(self) -> list[ToolHooks]: + """Return the per-tool inspection view of the registry. + + Returns: + One entry per tool with a subscription or a refusal, ordered + alphabetically by tool. The view states the fact of registration, + never the application. + """ + tools = sorted( + {subscription.tool for subscription in self._subscriptions} + | {rejection.tool for rejection in self._rejections} + ) + + return [ + ToolHooks( + tool=tool, + subscriptions=[subscription for subscription in self._subscriptions if subscription.tool == tool], + rejections=[rejection for rejection in self._rejections if rejection.tool == tool], + ) + for tool in tools + ] + + +@dataclass(kw_only=True) +class ToolContext: + """The isolated runtime context of one tool — its own state within a run. + + The only state a hook may write without restriction, unlike a delivered + domain context: a tool links the invocations of its hooks here and stays + invisible to the domains. + + Attributes: + tool: The environment-assigned tool identity of the owner. + """ + + tool: str + + +@dataclass(frozen=True, kw_only=True) +class ToolHooks: + """The per-tool inspection entry — one tool with its registrations. + + Attributes: + tool: The tool identity of the entry. + subscriptions: The tool's accepted subscriptions. + rejections: The tool's refused envelopes. + """ + + tool: str + subscriptions: list[Subscription] + rejections: list[RejectedRegistration] diff --git a/goga/hooks/tools/CODEMANIFEST b/goga/hooks/tools/CODEMANIFEST new file mode 100644 index 00000000..acdf34db --- /dev/null +++ b/goga/hooks/tools/CODEMANIFEST @@ -0,0 +1,225 @@ +Imports: + - Types: + - declared_actions + From: goga/hooks/catalog + +Usages: + convention: .goga/usages/conventions.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns the tool-facing surface of the platform: the identity of a + package derived from its pip name, the deterministic enumeration over the + environment, the facade import with the invocation of the single + registration callback, and the registration envelope with its + platform-side validation. Nothing else in the platform reaches the + packages or hands them a surface. A package runs at the trust level of + its installation — no isolation, no sandbox. The single fatal case of + the registry build lives here: a broken package import. Use relative + imports. + +--- + +"ToolPackage(module_name: str)": + location: packages.py + annotations: | + The identity of one installed tool package. + + `module_name`: the top-level module name of the installed package + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The tool identity is derived by dropping the goga_tool_ prefix and + turning underscores into hyphens — the canonical hyphen form of the + identity + - The facade is `module_name` verbatim — the importable facade module + - The identity is assigned by the environment — a package never names + itself + properties: + "tool -> str": | + The tool identity — the canonical hyphen form without the prefix. + "facade -> str": | + The importable name of the facade module. + +"enumerate_tool_packages() -> packages: list[ToolPackage]": + location: packages.py + annotations: | + Enumerate the installed tool packages of the environment. + + `packages`: one identity per installed package, in alphabetical order + of the top-level module name + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Read the installed distributions mapping of the environment + 2. Keep the top-level module names starting with the goga_tool_ + prefix + 3. Sort them alphabetically + 4. Wrap each into a package identity and return them + + Requirements: + - Deterministic — the same environment yields the same packages in the + same order on every run + - An environment without tool packages yields an empty list — not an + error + + Constraints: + - Do not import a package here — the facade import belongs to the + callback invocation + +"HookRegistrar(tool: str)": + location: registration.py + annotations: | + The controlled registration surface handed to one tool — the only way + a subscription enters the registry. Scoped to one tool identity: every + subscription made through the registrar is qualified by it. + + `tool`: the environment-assigned tool identity — the qualifier of every + registration made through this surface + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "tool -> str": | + The tool identity this surface qualifies registrations with. + "subscriptions -> list[Subscription]": | + The accepted subscriptions, in registration order. + "rejections -> list[RejectedRegistration]": | + The rejected envelopes, in attempted order. + methods: + "subscribe(domain: str, action: str, name: str, hook: Callable)": | + Register one hook subscription — the registration envelope. + + `domain`: the owner domain of the action + `action`: the action name within the domain + `name`: the hook name — unique per tool per address + `hook`: the callable executed when the action fires + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Resolve `domain` and `action` against `declared_actions` — an + unknown address is rejected + 2. Validate the envelope — a non-empty `name` and a callable `hook`; + a violation is rejected + 3. Reject a repeated registration of the same `name` on the same + address by this tool + 4. Every rejection is recorded and announced as a warning in the log + naming the tool, the action, and the reason + 5. An accepted envelope appends one subscription + + Requirements: + - A rejected envelope never cancels the accepted subscriptions of the + same tool — partial registrations survive + - The registrar never raises on an invalid envelope — rejection is + data, not an exception + + Constraints: + - Do not call `hook` or inspect its signature — the delivery + projection belongs to the delivery zone + - Do not resolve the tool identity — it is assigned by the caller + +"Subscription(tool: str, domain: str, action: str, name: str, hook: Callable)": + location: registration.py + annotations: | + One accepted subscription — a hook bound to an action address, + qualified by a tool identity. + + `tool`: the tool identity that registered the subscription + `domain`: the owner domain of the subscribed action + `action`: the subscribed action name + `name`: the hook name within its tool + `hook`: the registered callable + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "tool -> str": | + The tool identity that registered the subscription. + "domain -> str": | + The owner domain of the subscribed action. + "action -> str": | + The subscribed action name. + "name -> str": | + The hook name within its tool. + "hook -> Callable": | + The registered callable. + +"RejectedRegistration(tool: str, domain: str, action: str, name: str, reason: str)": + location: registration.py + annotations: | + One rejected registration envelope with the reason of the refusal — the + data behind the inspection view of refused registrations. + + `tool`: the tool identity that attempted the registration + `domain`: the owner domain of the addressed action + `action`: the addressed action name + `name`: the attempted hook name — an empty string when the envelope + did not carry one + `reason`: the reason of the refusal + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "tool -> str": | + The tool identity that attempted the registration. + "domain -> str": | + The owner domain of the addressed action. + "action -> str": | + The addressed action name. + "name -> str": | + The attempted hook name — empty when the envelope did not carry one. + "reason -> str": | + The reason of the refusal. + +"call_register_hooks(package: ToolPackage, registrar: HookRegistrar) -> invoked: bool": + location: packages.py + annotations: | + Import the facade of one tool package and run its registration callback. + + `package`: the identity of the target package + `registrar`: the registration surface scoped to the package's tool + `invoked`: True when the callback ran, False on a quiet skip + + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Import the facade module of `package` — a missing facade module is a + quiet skip + 2. A broken import of an existing package is a clean error naming the + package + 3. A registration callback on the facade is absent -> a quiet skip, + False + 4. Call the callback with `registrar` as its single argument -> True + + Requirements: + - A package without the callback is a normal condition — no warning and + no error + - The callback runs at the trust level of the installed package + - An exception raised by the callback propagates unchanged — the + isolation decision belongs to the caller + + Constraints: + - Do not pass any argument besides `registrar` — the callback contract + is a single registration surface + - Do not isolate or sandbox the callback + +--- + +Author: Goga +CreatedAt: 31/08/26 +Description: | + Owner of the tool-package access and registration — package identities, + enumeration, the facade callback, and the registration envelope. diff --git a/goga/hooks/tools/__init__.py b/goga/hooks/tools/__init__.py new file mode 100644 index 00000000..2c175ff2 --- /dev/null +++ b/goga/hooks/tools/__init__.py @@ -0,0 +1,19 @@ +"""Tool-package access and registration cell — the tool-facing surface. + +The owner of the package identities, the environment enumeration, the facade +callback invocation, and the registration envelope. A subscription enters the +platform through this surface alone. Importing the package imports no tool +package and enumerates nothing. +""" + +from .packages import ToolPackage, call_register_hooks, enumerate_tool_packages +from .registration import HookRegistrar, RejectedRegistration, Subscription + +__all__: list[str] = [ + "HookRegistrar", + "RejectedRegistration", + "Subscription", + "ToolPackage", + "call_register_hooks", + "enumerate_tool_packages", +] diff --git a/goga/hooks/tools/packages.py b/goga/hooks/tools/packages.py new file mode 100644 index 00000000..a5eac0d0 --- /dev/null +++ b/goga/hooks/tools/packages.py @@ -0,0 +1,115 @@ +"""The tool-package access of the hooks platform. + +The entities declared in the cell CODEMANIFEST with ``location: packages.py``: +the package identity ``ToolPackage``, the environment enumeration +``enumerate_tool_packages``, and the facade callback invocation +``call_register_hooks``. This module is the only place in the platform that +reaches the installed packages: identities are read from the environment, the +enumeration imports nothing, and the single import of a facade happens inside +the callback invocation. A package runs at the trust level of its +installation — no isolation, no sandbox. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from importlib import import_module +from importlib.metadata import packages_distributions +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # the registration envelope lives in the sibling module + from .registration import HookRegistrar + +_TOOL_PACKAGE_PREFIX = "goga_tool_" + + +@dataclass(frozen=True, kw_only=True) +class ToolPackage: + """The identity of one installed tool package. + + The identity is assigned by the environment — a package never names + itself. The record stores the top-level module name and derives the + canonical hyphen form from it; both are computed reads, never fields. + + Attributes: + module_name: The top-level module name of the installed package. + + Requirements: + ``tool`` drops the ``goga_tool_`` prefix and turns underscores into + hyphens; ``facade`` is ``module_name`` verbatim. + """ + + module_name: str + + @property + def tool(self) -> str: + """The tool identity — the canonical hyphen form without the prefix.""" + return self.module_name.removeprefix(_TOOL_PACKAGE_PREFIX).replace("_", "-") + + @property + def facade(self) -> str: + """The importable name of the facade module.""" + return self.module_name + + +def enumerate_tool_packages() -> list[ToolPackage]: + """Enumerate the installed tool packages of the environment. + + One identity per installed package, in alphabetical order of the + top-level module name. The environment is only read here — no package is + imported: the facade import belongs to the callback invocation. An + environment without tool packages yields an empty list, not an error. + + Returns: + One identity per installed tool package, alphabetically ordered. + """ + names = sorted(name for name in packages_distributions() if name.startswith(_TOOL_PACKAGE_PREFIX)) + + return [ToolPackage(module_name=name) for name in names] + + +def call_register_hooks(package: ToolPackage, registrar: HookRegistrar) -> bool: + """Import the facade of one tool package and run its registration callback. + + The single import of a tool package in the whole platform. A missing + facade module and a facade without a callable ``register_hooks`` are + normal conditions — a quiet skip, no warning and no error. A broken + import of an existing package is the single fatal case of the platform: + a clean error naming the package. An exception of the callback itself + propagates unchanged — the isolation decision belongs to the caller. + + Args: + package: The identity of the target package. + registrar: The registration surface scoped to the package's tool. + + Returns: + True when the callback ran, False on a quiet skip. + + Raises: + ImportError: The package exists but its facade fails to import — the + message names the package. + """ + try: + module = import_module(package.facade) + + except ModuleNotFoundError as exc: + # The import machinery records on `exc.name` the module it could not + # resolve, and that equals the facade only when the tool package + # itself is missing. A deeper miss means the package was found — the + # honest cause must not be masked by a quiet skip. + if exc.name == package.facade: + return False + + raise ImportError(f"package {package.facade} failed to import: {exc}") from exc + + except Exception as exc: # a facade that fails to parse, and the like + raise ImportError(f"package {package.facade} failed to import: {exc}") from exc + + callback = getattr(module, "register_hooks", None) + + if not callable(callback): + return False + + callback(registrar) + + return True diff --git a/goga/hooks/tools/registration.py b/goga/hooks/tools/registration.py new file mode 100644 index 00000000..ffa09358 --- /dev/null +++ b/goga/hooks/tools/registration.py @@ -0,0 +1,180 @@ +"""The registration envelope of the hooks platform. + +The entities declared in the cell CODEMANIFEST with ``location: +registration.py``: the registration surface ``HookRegistrar`` and the two +value records ``Subscription`` and ``RejectedRegistration``. This module is +the only way a subscription enters the platform — an address is resolved +against the catalog, the envelope is validated, and a refusal is recorded as +data with a log warning, never raised. The registrar never calls a hook +and never resolves a tool identity: the identity is assigned by the caller +that owns the package. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass, field + +from ..catalog import declared_actions + +logger = logging.getLogger(__name__) + + +@dataclass(kw_only=True) +class HookRegistrar: + """The controlled registration surface handed to one tool. + + Scoped to one tool identity: every registration made through the surface + is qualified by it. An invalid envelope is refused as data — the registrar + never raises on one, and a refusal never cancels the accepted + registrations of the same tool. + + Attributes: + tool: The tool identity every registration made through this surface + is qualified with — assigned by the caller, never resolved here. + """ + + tool: str + _subscriptions: list[Subscription] = field(init=False, default_factory=list, repr=False) + _rejections: list[RejectedRegistration] = field(init=False, default_factory=list, repr=False) + + @property + def subscriptions(self) -> list[Subscription]: + """The accepted subscriptions, in registration order — a read copy.""" + return list(self._subscriptions) + + @property + def rejections(self) -> list[RejectedRegistration]: + """The rejected envelopes, in attempted order — a read copy.""" + return list(self._rejections) + + def subscribe(self, domain: str, action: str, name: str, hook: Callable[..., object]) -> None: + """Register one hook subscription — the registration envelope. + + Args: + domain: The owner domain of the action. + action: The action name within the domain. + name: The hook name — unique per tool per address. + hook: The callable executed when the action fires. + + Algorithm: + 1. Resolve ``domain`` and ``action`` against ``declared_actions`` + — an unknown address is rejected + 2. Validate the envelope — a non-empty ``name`` and a callable + ``hook``; a violation is rejected + 3. Reject a repeated registration of the same ``name`` on the + same address by this tool + 4. Every rejection is recorded and announced as a warning in + the log naming the tool, the action, and the reason + 5. An accepted envelope appends one subscription + + Requirements: + A rejected envelope never cancels the accepted subscriptions of + the same tool, and the registrar never raises on an invalid + envelope — rejection is data, not an exception. + + Constraints: + Do not call ``hook`` or inspect its signature — the delivery + projection belongs to the delivery zone. + + A rejection carries the attempted name as an empty string when the + envelope did not carry a usable one, so the inspection view states + what was refused even for an ill-formed name. + """ + + def reject(reason: str) -> None: + self._rejections.append( + RejectedRegistration( + tool=self.tool, + domain=domain, + action=action, + name=name if isinstance(name, str) else "", + reason=reason, + ) + ) + + logger.warning("rejected hook of tool %s on %s.%s: %s", self.tool, domain, action, reason) + + known = any(record.domain == domain and record.name == action for record in declared_actions()) + + if not known: + reject(f"unknown action {domain}.{action}") + + return + + if not (isinstance(name, str) and name): + reject("name must be a non-empty string") + + return + + if not callable(hook): + reject("hook must be callable") + + return + + repeated = any( + subscription.domain == domain and subscription.action == action and subscription.name == name + for subscription in self._subscriptions + ) + + if repeated: + reject("repeated name on the same address") + + return + + self._subscriptions.append(Subscription(tool=self.tool, domain=domain, action=action, name=name, hook=hook)) + + +@dataclass(frozen=True, kw_only=True) +class Subscription: + """One accepted subscription — a hook bound to an address, qualified by a tool. + + The record carries no behavior: the delivery decides how the hook is + called, the registry decides when. + + Attributes: + tool: The tool identity that registered the subscription. + domain: The owner domain of the subscribed action. + action: The subscribed action name. + name: The hook name within its tool. + hook: The registered callable. + + Requirements: + The identity of the record is the triple ``tool``, ``domain``, + ``action`` with the ``name`` — the registrar enforces its uniqueness + per tool per address before a subscription exists. + """ + + tool: str + domain: str + action: str + name: str + hook: Callable[..., object] + + +@dataclass(frozen=True, kw_only=True) +class RejectedRegistration: + """One refused registration envelope with the reason of the refusal. + + The data behind the inspection view of refused registrations — it states + what was attempted and why it did not apply, nothing more. + + Attributes: + tool: The tool identity that attempted the registration. + domain: The owner domain of the addressed action. + action: The addressed action name. + name: The attempted hook name — an empty string when the envelope did + not carry a usable one. + reason: The reason of the refusal. + + Requirements: + The reason is one of the platform refusal strings, so the inspection + view stays stable for the reader. + """ + + tool: str + domain: str + action: str + name: str + reason: str diff --git a/goga/onboarding/CODEMANIFEST b/goga/onboarding/CODEMANIFEST index 00dd6905..a23f16be 100644 --- a/goga/onboarding/CODEMANIFEST +++ b/goga/onboarding/CODEMANIFEST @@ -19,11 +19,11 @@ Usages: For languages with predefined images, display a list of suggestions; default to the last entry. Accept arbitrary user input for the image name. Language → available image mapping: - - python: qarium/goga-python-{3.10-3.14}:1.2 - - golang: qarium/goga-golang-{1.23, 1.24, 1.25, 1.26}:1.2 - - javascript: qarium/goga-node-{22, 24}:1.2 - - kotlin: qarium/goga-kotlin-{2.0, 2.1, 2.2, 2.3}:1.2 - - swift: qarium/goga-swift-{6.0, 6.1, 6.2}:1.2 + - python: qarium/goga-python-{3.10-3.14}:1.3 + - golang: qarium/goga-golang-{1.23, 1.24, 1.25, 1.26}:1.3 + - javascript: qarium/goga-node-{22, 24}:1.3 + - kotlin: qarium/goga-kotlin-{2.0, 2.1, 2.2, 2.3}:1.3 + - swift: qarium/goga-swift-{6.0, 6.1, 6.2}:1.3 agent_env_defaults: | Map each agent to a list of environment variable keys for prompting. Display the keys to the user; collect corresponding values. diff --git a/goga/onboarding/questionnaire.py b/goga/onboarding/questionnaire.py index cff56550..fb2229d2 100644 --- a/goga/onboarding/questionnaire.py +++ b/goga/onboarding/questionnaire.py @@ -9,32 +9,32 @@ _IMAGE_MAP: dict[str, list[str]] = { "python": [ - "qarium/goga-python-3.10:1.2", - "qarium/goga-python-3.11:1.2", - "qarium/goga-python-3.12:1.2", - "qarium/goga-python-3.13:1.2", - "qarium/goga-python-3.14:1.2", + "qarium/goga-python-3.10:1.3", + "qarium/goga-python-3.11:1.3", + "qarium/goga-python-3.12:1.3", + "qarium/goga-python-3.13:1.3", + "qarium/goga-python-3.14:1.3", ], "golang": [ - "qarium/goga-golang-1.23:1.2", - "qarium/goga-golang-1.24:1.2", - "qarium/goga-golang-1.25:1.2", - "qarium/goga-golang-1.26:1.2", + "qarium/goga-golang-1.23:1.3", + "qarium/goga-golang-1.24:1.3", + "qarium/goga-golang-1.25:1.3", + "qarium/goga-golang-1.26:1.3", ], "javascript": [ - "qarium/goga-node-22:1.2", - "qarium/goga-node-24:1.2", + "qarium/goga-node-22:1.3", + "qarium/goga-node-24:1.3", ], "kotlin": [ - "qarium/goga-kotlin-2.0:1.2", - "qarium/goga-kotlin-2.1:1.2", - "qarium/goga-kotlin-2.2:1.2", - "qarium/goga-kotlin-2.3:1.2", + "qarium/goga-kotlin-2.0:1.3", + "qarium/goga-kotlin-2.1:1.3", + "qarium/goga-kotlin-2.2:1.3", + "qarium/goga-kotlin-2.3:1.3", ], "swift": [ - "qarium/goga-swift-6.0:1.2", - "qarium/goga-swift-6.1:1.2", - "qarium/goga-swift-6.2:1.2", + "qarium/goga-swift-6.0:1.3", + "qarium/goga-swift-6.1:1.3", + "qarium/goga-swift-6.2:1.3", ], } diff --git a/goga/pipeline/CODEMANIFEST b/goga/pipeline/CODEMANIFEST index f66e7ec1..0500b30a 100644 --- a/goga/pipeline/CODEMANIFEST +++ b/goga/pipeline/CODEMANIFEST @@ -8,6 +8,7 @@ Imports: - compile-flow - parse-dsl - serialize-flow + - memory-emission From: goga/pipeline/compiler - Types: - run_flow @@ -25,6 +26,7 @@ Imports: - WorkflowStage Usages: - parse-workflow + - memory From: goga/pipeline/workflow - Types: - resolve_project_name @@ -125,7 +127,8 @@ Annotations: | `compile-flow`) to transform the discovered pipeline-file into an afm flow-file at runtime inside the container; use `parse-dsl` and `serialize-flow` for the intermediate stages when a lower-level view is - required. + required. Use `memory-emission` for the compiled memory surface of the + flow-file — the top-level memory block and the per-stage memory keys. Prompt materialization step: after compilation, run coordination materializes the four default agent prompt files (per `default_prompts`) @@ -612,7 +615,9 @@ Annotations: | 4. Return a NEW `WorkflowDocument`: prompt = the prompt of `workflow` when `workflow` is not None else None; stages = the new map; extend = a copy of the extend map of `workflow` when `workflow` is not None else the - default empty map. The input `workflow` and its maps are NOT mutated + default empty map; memory = the memory field of `workflow` when + `workflow` is not None else None — carried verbatim. The input + `workflow` and its maps are NOT mutated Requirements: - Empty `skip_stages` is a no-op — return the input unchanged @@ -624,7 +629,10 @@ Annotations: | `WorkflowDocument` whose stages map carries only the skip entries (prompt None, extend empty); skip applies to a workflow-less pipeline - Construct `WorkflowStage` with skip=True and all other fields at their - defaults + defaults — notes stays None (the model field default) + - The memory configuration of the input workflow survives the rebuild + verbatim — a rebuild that drops it would silently disable memory + participation for skip-driven runs - Stage-name validation is NOT performed here — the compiler's strict check raises a structural error on a name absent from the pipeline body; this routine stays declarative @@ -636,6 +644,8 @@ Annotations: | - Do not write, read, or generate any workflow-file — the merge operates purely on in-memory Python objects - Do not mutate the input `workflow` object or its maps + - Do not interpret, validate, or rebuild the memory configuration — it is + carried as an opaque value (per `memory`) "pipeline_cli(argv: list[str]) -> exit_code: int": location: cli.py diff --git a/goga/pipeline/apply_skip_stages.py b/goga/pipeline/apply_skip_stages.py index 57ab2bb4..e44e40f7 100644 --- a/goga/pipeline/apply_skip_stages.py +++ b/goga/pipeline/apply_skip_stages.py @@ -7,7 +7,9 @@ name in ``skip_stages`` is applied as a fresh :class:`~goga.pipeline.workflow.WorkflowStage` carrying ``skip=True`` over a copy of the workflow's stages map, so skip always wins over any pre-existing -override for that name. +override for that name. The rebuilt document carries the input's +``prompt``/``extend``/``memory`` — the memory configuration verbatim, as an +opaque value. This Routine is intentionally declarative — it only PREPARES the document the compiler consumes. It does NOT delete stages or rewrite ``depends_on`` (the @@ -32,19 +34,24 @@ def apply_skip_stages(workflow: WorkflowDocument | None, skip_stages: list[str]) COPY of the workflow's stages map, so a name present in both the workflow stages and ``skip_stages`` is replaced wholesale (skip always wins). A NEW :class:`~goga.pipeline.workflow.WorkflowDocument` is returned carrying the - merged map, the input's ``prompt``/``extend`` (or ``None``/empty when the - input is ``None``). The input document and its maps are never touched. + merged map, the input's ``prompt``/``extend``/``memory`` (or + ``None``/empty/``None`` when the input is ``None``). The input document and + its maps are never touched. Requirements: - Empty ``skip_stages`` is a no-op — return the input unchanged (``None`` stays ``None``; no skip applied). - Skip always wins — a name present in both the workflow stages and ``skip_stages`` is replaced with a ``WorkflowStage(skip=True)`` (all - other fields at defaults). + other fields at defaults — ``notes`` stays ``None``, the model-field + default). - Do not mutate the input ``workflow`` or its stages/extend maps. - When ``workflow`` is ``None`` and ``skip_stages`` is non-empty, construct a document whose stages map carries only the skip entries - (``prompt=None``, ``extend={}``). + (``prompt=None``, ``extend={}``, ``memory=None``). + - The memory configuration of the input workflow survives the rebuild + verbatim — a rebuild that drops it would silently disable memory + participation for skip-driven runs. - Stage-name validation is NOT performed here — the compiler's strict check raises a structural error on a name absent from the pipeline body; this Routine stays declarative. @@ -58,6 +65,10 @@ def apply_skip_stages(workflow: WorkflowDocument | None, skip_stages: list[str]) Returns: The resulting ``WorkflowDocument`` carrying the skip directives, or the input unchanged when ``skip_stages`` is empty (``None`` stays ``None``). + + Constraints: + - Do not interpret, validate, or rebuild the memory configuration — it + is carried as an opaque value (per the ``memory`` practice). """ # Step 1 — empty skip is a no-op (None stays None; input returned as-is). if not skip_stages: @@ -70,9 +81,11 @@ def apply_skip_stages(workflow: WorkflowDocument | None, skip_stages: list[str]) for name in skip_stages: new_stages[name] = WorkflowStage(skip=True) - # Step 4 — return a NEW document; the input is NOT mutated. + # Step 4 — return a NEW document; the input is NOT mutated. The memory + # configuration rides along verbatim (same object, opaque — never rebuilt). return WorkflowDocument( prompt=workflow.prompt if workflow is not None else None, stages=new_stages, extend=dict(workflow.extend) if workflow is not None else {}, + memory=workflow.memory if workflow is not None else None, ) diff --git a/goga/pipeline/compiler/.usages/compile-flow.md b/goga/pipeline/compiler/.usages/compile-flow.md index 2408434b..d5c4f38e 100644 --- a/goga/pipeline/compiler/.usages/compile-flow.md +++ b/goga/pipeline/compiler/.usages/compile-flow.md @@ -8,8 +8,9 @@ and detects the body format, applies per-stage workflow overrides + loop-expansi ## Stage-body field translation (canonical order) Output FlowStage fields, canonical order: -interactive, auto_approve, auto_run, command, prompt, description, agents, supervisor, -supervisor_prompt, skills, script_before, script, script_after, script_timeout, <unknown A-Z>. +interactive, auto_approve, auto_run, command, prompt, description, buttons, agents, supervisor, +supervisor_prompt, skills, script_before, script, script_after, script_timeout, reflect, +memory_use, <unknown A-Z>. Authoring key → output key (the authoring key is consumed, not passed through): - communication → interactive (value true/false) @@ -18,13 +19,24 @@ Authoring key → output key (the authoring key is consumed, not passed through) - script → script - after_script → script_after - timeout → script_timeout (verbatim str; requires script; omitempty) +- notes (workflow.stages.<name>) → buttons (map verbatim; slot right after + description; omitempty) +- reflect (workflow.stages.<name>.reflect, reflect method) → reflect + (map {file, mode}; slot right after script_timeout; omitempty) +- memory (workflow.stages.<name>.memory, alignment method) → memory_use + (bool; True on a participating stage, explicit false on every + non-participating one when the block is emitted; omitempty) - roles → agents (via translate_role; default ["auto"] when absent/empty). In a body carrying `script`, NO agents key is emitted at all (afm rejects the combination) — neither the default nor a translated roles value — while the roles elements are still validated (a non-str element → structural error, same as without script) An authoring auto_run key is rejected ("auto_run key is forbidden in stage body; -use trigger: manual") — auto_run is a runtime key, authored as trigger. +use trigger: manual") — auto_run is a runtime key, authored as trigger. An +authoring reflect or memory_use key is likewise rejected ("reflect key is +forbidden in stage body; use reflect in workflow.stages" / "memory_use key is +forbidden in stage body; use memory in workflow.stages") — the memory stage +keys are compiled exclusively from the workflow instructions. ## trigger (stage-body: on_success | manual) & manual (workflow: bool) @@ -58,6 +70,30 @@ Interactions: - loop-expansion: every copy of a manual stage carries `auto_run: false` - `PipelineDocument` is unaffected (output-side only; source bodies never mutated) +## notes (workflow directive: map str→str) → buttons (flow-file) + +`notes` is a per-stage workflow instruction carrying note buttons — a map of +"note name → prompt text". The compiler translates it into the afm per-stage +key `buttons`. + +- authoring source is SINGLE: `workflow.stages.<name>.notes`. An authoring + `buttons` key in a stage body (pipeline-file stage or extend-stage body) is a + structural error — "buttons key is forbidden in stage body; use notes in + workflow.stages" +- a non-empty notes map assembles `buttons` (the map verbatim — keys and values + unchanged) into the canonical slot immediately after `description` +- `notes: {}` (empty map) equals absence — no `buttons` key in the output +- `notes` applies per stage name to extend-stages as well (note buttons of a new + stage are authored as `stages.<new-stage-name>.notes`; `notes` in an + extend-entry is rejected by the workflow parser) +- loop-expanded copies carry the same `buttons` +- `skip: true` wins — the stage is removed before the notes application +- a name absent from both the pipeline body and the extend-stages raises the + existing structural error "unknown stage name in workflow.stages: <name>" +- `PipelineDocument` is unaffected (output-side only; source bodies never mutated) +- interpretation of the buttons belongs to afm — the compiler only assembles and + serializes the field + ## approve (workflow directive: auto | plan | dialog) `approve` is a per-stage / extend-entry workflow field — a declarative directive @@ -126,7 +162,7 @@ body) translated to the afm per-stage key `script_timeout`. ## Key presence -Stages without approve, without script directives, without communication/roles -changes, and without a manual-effective trigger produce no auto_approve / -auto_run / script_* / script_timeout keys — those keys appear only when their -source directive is present. +Stages without approve, without notes, without script directives, without +communication/roles changes, and without a manual-effective trigger produce +no auto_approve / buttons / auto_run / script_* / script_timeout keys — those +keys appear only when their source directive is present. diff --git a/goga/pipeline/compiler/.usages/memory-emission.md b/goga/pipeline/compiler/.usages/memory-emission.md new file mode 100644 index 00000000..65a25b40 --- /dev/null +++ b/goga/pipeline/compiler/.usages/memory-emission.md @@ -0,0 +1,87 @@ +# memory-emission — compiling memory into the afm flow-file + +The document describes how the compiler handles workflow memory: when the +global `memory` block is emitted, which keys the stages receive, and which +defaults are materialized. The audience is compiler consumers and +workflow-file authors checking the expected output. + +## The emission condition + +The global `memory` block is emitted **if and only if at least one stage +participates in memory**. Participation: a `reflect` instruction under the +reflect method; `memory: true` under the alignment method. The `memory:` +block of a workflow is configuration, not a switch. + +| # | `memory:` block | Stage instructions | Block in the output? | +|---|----------------|--------------------|----------------------| +| 1 | absent | absent | no | +| 2 | absent | `reflect` present | yes | +| 3 | present (configuration only) | absent | no — a silent no-op | +| 4 | present, alignment | `memory: true` present | yes | +| 5 | present, alignment | absent (including all `false`) | no — a silent no-op | +| 6 | present, reflect | `reflect` present | yes | + +When the block is absent — **nothing** is written on the stages, including +the opting-out key. + +## Block content (per method) + +The block sits between `description` and `stages`; the key order is `path, +mode, memory_use, max_rules, commit`: + +| Key | reflect | alignment | +|-----|---------|-----------| +| `path` | the joined memory root | the joined memory root | +| `mode` | `r` (fixed) | the materialized authored value (`rw` by default) | +| `memory_use` | `false` | `false` | +| `max_rules` | from the configuration | from the configuration | +| `commit` | from the configuration | from the configuration | + +The global `memory_use: false` is an opting-out default: participation in +memory is strictly per-stage (afm computes `UseFor(stage) = +stage.memory_use ?? memory.memory_use`, so the global opt-out does not +enable memory on stages without an explicit key). Under reflect a stage +participates through the `reflect` key (`mode: r` gives read-only access to +the project memory); under alignment — through the stage-level +`memory_use: true`. + +`path` = `.goga/memory` (no suffix) or `.goga/memory/<suffix>`. + +When the `memory:` block is not authored in the workflow (case 2 — only +`reflect` instructions present), the values come from the materialized +defaults: `path` — the bare root `.goga/memory`, `max_rules: 25`, +`commit: false`. The single source of the defaults is the field defaults of +the `WorkflowMemory` model. + +## Stage keys + +The canonical position is after `script_timeout` (the tail of the known +keys): + +- reflect method: a stage with a `reflect` instruction gets the `reflect` + key — `file` verbatim, `mode` materialized (`rw` when not authored) +- alignment method (with the block emitted): a marked stage — + `memory_use: true`; **every** unmarked one — an explicit `memory_use: false` +- loop copies carry the same keys as the original; skipped stages never + reach the application + +The goga method selector never reaches the output. + +## Invariants + +- a workflow without memory participation compiles byte-identically — no + block, no stage keys +- `PipelineDocument` is the exact mirror of the source pipeline-file: the + memory block and the memory stage keys are output-side only +- the `compile_flow`/`serialize_flow` signatures are unaffected by memory + participation + +## Anti-patterns + +- Do not author `reflect`/`memory_use` in a stage body — a structural + error; the single source is the workflow instructions +- Do not assume an unset stage key is safe: the inheritance of the global + default is the reason for the explicit `memory_use: false` on unmarked + stages +- Do not re-check the authoring vocabulary on the compiler side — the + workflow parser rejects it before compilation diff --git a/goga/pipeline/compiler/.usages/serialize-flow.md b/goga/pipeline/compiler/.usages/serialize-flow.md index c073b601..e91045f5 100644 --- a/goga/pipeline/compiler/.usages/serialize-flow.md +++ b/goga/pipeline/compiler/.usages/serialize-flow.md @@ -6,9 +6,9 @@ flow-style agents, block-style skills/depends_on/top-level prompt). ## Canonical per-stage key order -interactive, auto_approve, auto_run, command, prompt, description, agents, +interactive, auto_approve, auto_run, command, prompt, description, buttons, agents, supervisor, supervisor_prompt, skills, script_before, script, script_after, -script_timeout, <unknown A-Z>. +script_timeout, reflect, memory_use, <unknown A-Z>. The serializer does NOT reorder — canonical order is fixed at `FlowStage` assembly (`compile_flow`); `serialize_flow` iterates `fields` as-is. @@ -18,15 +18,27 @@ assembly (`compile_flow`); `serialize_flow` iterates `fields` as-is. - agents: flow-style - skills, depends_on, top-level prompt: block-style - auto_approve, auto_run: plain bool scalars +- buttons: a block-style mapping; each value — a plain scalar when single-line, + a block-literal scalar when multi-line (the script-family pattern) - script_before/script/script_after: plain scalars when single-line; block-literal scalars when multi-line - script_timeout: plain scalar when single-line; block-literal scalar when multi-line (the script-family pattern) +- reflect: a block-style mapping of plain scalars (file, mode) +- memory_use: a plain bool scalar - empty list values (e.g. explicit empty depends_on) written explicitly ## Key presence -Stages without auto_approve / auto_run / script_before / script / script_after / -script_timeout serialize without those keys — each appears only when its source -directive is present. `auto_run` appears only for a manual-effective stage, -always as `auto_run: false`. +Stages without auto_approve / auto_run / buttons / script_before / script / +script_after / script_timeout / reflect / memory_use serialize without those +keys — each appears only when its source directive is present. `auto_run` +appears only for a manual-effective stage, always as `auto_run: false`. +`buttons` appears only when the workflow supplied a non-empty notes instruction +for the stage. + +A flow document without memory participation serializes without the top-level +`memory` block and without any memory stage key — byte-identical output for +memory-free workflows. When present, the block sits between `description` and +`stages` with the key order path, mode, memory_use, max_rules, commit (a None +field omitted entirely). diff --git a/goga/pipeline/compiler/CODEMANIFEST b/goga/pipeline/compiler/CODEMANIFEST index 332918da..2a13c048 100644 --- a/goga/pipeline/compiler/CODEMANIFEST +++ b/goga/pipeline/compiler/CODEMANIFEST @@ -3,13 +3,17 @@ Imports: - WorkflowDocument - WorkflowStage - WorkflowExtendStage + - WorkflowMemory + - WorkflowReflect Usages: - parse-workflow + - memory From: goga/pipeline/workflow Usages: convention: .goga/usages/conventions.md beautiful_yaml: .goga/usages/cooks/beautiful_yaml.md + afm: .goga/usages/cooks/afm.md Annotations: | The `convention` practice is used for: @@ -22,7 +26,8 @@ Annotations: | This cell is a pure transformer: it reads a pipeline-file written in goga DSL (phases-list or stages-map) and writes an equivalent afm flow-file (flat YAML with top-level prompt (when present), top-level root_dir (when - supplied by the caller), name, description, and a stages list). It performs + supplied by the caller), name, description, memory (when memory + participates), and a stages list). It performs no I/O beyond the two paths it receives and the optional workflow-file instruction source, performs no network or subprocess calls, and performs no environment-variable reads — the caller supplies the root_dir value @@ -52,7 +57,7 @@ Annotations: | segment before is the header (name, description, optional roles), the segment after is the body (either a YAML list for phases or a YAML dict for stages). The output flow-file is NOT segmented — it is a single flat - YAML document with optional prompt, name, description, and stages. + YAML document with optional prompt, name, description, memory, and stages. `BodyFormat` detection is structural: a list body yields PHASES (auto-generates depends_on by position), a dict body yields STAGES (passes @@ -76,9 +81,10 @@ Annotations: | responsibility, not this cell's. Canonical `FlowStage` fields key order: interactive, auto_approve, auto_run, - command, prompt, description, agents, supervisor, supervisor_prompt, skills, - script_before, script, script_after, script_timeout, then alphabetically- - sorted unknown keys. command is populated from the agent + command, prompt, description, buttons, agents, supervisor, supervisor_prompt, + skills, script_before, script, script_after, script_timeout, reflect, + memory_use, then alphabetically-sorted unknown keys. command is populated + from the agent field of `WorkflowStage` (composed as /home/goga/bin/AGENT-as-claude.sh); description is populated from the prompt field of `WorkflowStage`. Both are independent channels — pipeline-file prompt and workflow-file prompt @@ -259,6 +265,21 @@ Annotations: | Loop-expanded copies carry the stage's effective trigger, so every copy of a manual stage assembles auto_run: false. + Workflow notes instruction: when the workflow stages block carries notes + for a stage (a map of note name → prompt text on `WorkflowStage`), the + compiler assembles the per-stage buttons field of the flow-file in the + canonical slot immediately after description; the map passes through + verbatim — keys and values unchanged. An authoring buttons key in a stage + body (pipeline-file stage OR embedded extend-stage) is a structural error + "buttons key is forbidden in stage body; use notes in workflow.stages" — + buttons are authored ONLY through the workflow notes instruction (a single + authoring source; no collisions). The instruction applies per stage name to + embedded extend-stages as well; loop-expanded copies carry the same buttons; + a skipped stage never reaches the application (skip removal runs first). + Interpretation of the buttons belongs to afm — the compiler only assembles + and serializes the field. Output-side only — `PipelineDocument` stays the + faithful mirror of the source pipeline-file. + Stage script directives: before_script, script, after_script are string stage-body directives translated to script_before, script, script_after (the authoring keys are consumed, not passed through). A stage body that carries @@ -279,6 +300,22 @@ Annotations: | the translated value verbatim; PipelineDocument bodies stay untouched (output-side only). + Workflow memory emission: when the supplied workflow carries memory + participation, the cell assembles the top-level memory block of the + flow-file and the per-stage memory keys. The block is emitted if and only + if at least one stage participates in memory — the memory configuration + alone never turns the block on. A stage participates by carrying a reflect + instruction under the reflect method, or a true memory instruction under + the alignment method. The emitted path composes the fixed memory root with + the authored suffix. The goga-side method selector never reaches the + output. Apply `afm` for the external contract of the emitted memory keys. + Apply `memory` for the authoring vocabulary of the consumed workflow + instructions. The stage-body keys reflect and memory_use are + authoring-forbidden — the memory stage keys are compiled exclusively from + the workflow instructions; an authored occurrence in any stage body is a + structural error. Output-side only — `PipelineDocument` stays the faithful + mirror of the source pipeline-file. + --- "BodyFormat()": @@ -564,16 +601,28 @@ Annotations: | `depends_on`: predecessor step ids, or None. None produces no depends_on key in output; an empty list produces depends_on []. `fields`: extra step fields in canonical key order (interactive, - auto_approve, auto_run, command, prompt, description, agents, - supervisor, supervisor_prompt, skills, script_before, script, - script_after, script_timeout, then alphabetically-sorted - unknown keys). + auto_approve, auto_run, command, prompt, description, buttons, + agents, supervisor, supervisor_prompt, skills, script_before, + script, script_after, script_timeout, reflect, memory_use, + then alphabetically-sorted unknown keys). + buttons (map of str→str) is present only when the workflow + supplied a non-empty notes instruction for the stage — the map + passes through verbatim. auto_run (bool) is present only when the stage's effective trigger is manual — the value is always False; auto_run: true is never assembled. script_before/script/script_after (str) are present only when the corresponding stage directive was authored; script_timeout (str) is present only when the - corresponding directive was authored. Insertion order of + corresponding directive was authored. reflect (map of file + + mode) is present only when the block is emitted and the + stage's reflect instruction is effective — the authored file + verbatim, the materialized mode; uniform across every + loop-expanded copy. memory_use (bool) is present only when + the block is emitted under the alignment method — True on a + participating stage, explicit False on every non-participating + one. Both occupy the canonical slots after script_timeout. A + stage of a memory-free workflow carries neither key. + Insertion order of this dict IS the output order — the serializer iterates it as-is. command is populated from the agent field of `WorkflowStage` (composed as the in-container @@ -620,26 +669,87 @@ Annotations: | Predecessor step ids, or None when absent. "fields -> dict[str, Any]": | Extra fields in canonical key order: interactive, auto_approve, - auto_run, command, prompt, description, agents, supervisor, + auto_run, command, prompt, description, buttons, agents, supervisor, supervisor_prompt, skills, script_before, script, script_after, - script_timeout, then alphabetically-sorted unknown keys. auto_approve + script_timeout, reflect, memory_use, then alphabetically-sorted unknown + keys. buttons + (map of str→str) is present only when the workflow supplied a + non-empty notes instruction for the stage. auto_approve (bool) is present only when the effective approve directive drives the roles effect ("auto"/"dialog") + planner-in-roles fired; auto_run (bool) is present only when the stage's effective trigger is manual — the value is always False, auto_run: true is never assembled; script_before/script/ script_after/script_timeout (str) are present only when the corresponding - stage directive was authored. Insertion order IS the output order — the + stage directive was authored. reflect (map of file + mode) is present + only when the block is emitted and the stage's reflect instruction is + effective — the authored file verbatim, the materialized mode; uniform + across every loop-expanded copy. memory_use (bool) is present only when + the block is emitted under the alignment method — True on a + participating stage, explicit False on every non-participating one. + Both occupy the canonical slots after script_timeout. A stage of a + memory-free workflow carries neither key. Insertion order IS the output order — the serializer iterates as-is. command/description from WorkflowStage; agents default-injected to ["auto"], absent entirely in a body carrying script; supervisor/supervisor_prompt only when authored. -"FlowDocument(prompt: str | None = None, root_dir: str | None = None, name: str, description: str, stages: list[FlowStage])": +"FlowMemory(path: str, mode: str | None = None, memory_use: bool | None = None, max_rules: int, commit: bool)": + location: flow_memory.py + annotations: | + Emitted top-level memory block of the flow-file — the compiled form of + the workflow-memory configuration. Built by `compile_flow` when memory + participates; consumed by `serialize_flow`. + + `path`: the composed memory root — the fixed root joined with the + authored suffix (the bare root when no suffix) + `mode`: the project-memory access mode — the fixed "r" for the reflect + method (read-only project memory); the materialized authored + value ("rw" by default) for the alignment method + `memory_use`: the global participation default — always False in the + emitted block (participation is per-stage opt-in; afm's + UseFor(stage) inherits this default for an unset stage key) + `max_rules`: the maximum number of memory rules (always >= 1) + `commit`: whether memory changes are committed + + Build the data model with the standard library dataclasses module + (NOT pydantic, per `convention`). Use @dataclass(kw_only=True). + + Requirements: + - Use @dataclass(kw_only=True) (per `convention`) + - Field order is fixed: path, mode, memory_use, max_rules, commit — the + emission order of the block keys + - reflect method: mode "r", memory_use False; alignment method: mode the + materialized authored value ("rw" by default), memory_use False + - A None field is omitted from the output entirely + + Constraints: + - Do not decide here whether the block is emitted — `compile_flow` + decides on participation + - Do not compose `path` here — the caller composes the root and suffix + - Do not interpret the block — the runtime behavior belongs to afm (per + `afm`) + properties: + "path -> str": | + The composed memory root — the fixed root joined with the authored + suffix. + "mode -> str | None": | + The project-memory access mode — "r" for the reflect method, the + materialized authored value for the alignment method. + "memory_use -> bool | None": | + The global participation default; False in every emitted block + (participation is per-stage opt-in). + "max_rules -> int": | + The maximum number of memory rules; always >= 1. + "commit -> bool": | + Whether memory changes are committed. + +"FlowDocument(prompt: str | None = None, root_dir: str | None = None, name: str, description: str, memory: FlowMemory | None = None, stages: list[FlowStage])": location: flow_document.py annotations: | - Output afm flow-file — a single flat YAML document with up to five + Output afm flow-file — a single flat YAML document with up to six top-level keys (prompt (when present), root_dir (when supplied), - name, description, stages). No segmentation, no header sub-object: + name, description, memory (when memory participates), stages). No + segmentation, no header sub-object: the format is flat, and this type mirrors that flatness. `prompt`: top-level prompt value emitted as the FIRST top-level key of @@ -656,6 +766,10 @@ Annotations: | None when the caller did not supply one (omitted from output — no top-level root_dir key in the flow-file). The compiler itself performs no environment-variable reads. + `memory`: the compiled memory block, or None when memory does not + participate. Emitted between description and stages when not + None; omitted entirely when None — byte-identical output for + memory-free workflows. `name`: top-level name value (carried 1:1 from `PipelineHeader` name) `description`: top-level description value (carried 1:1 from `PipelineHeader` description) @@ -676,8 +790,9 @@ Annotations: | key when no workflow supplies one - `root_dir` defaults to None — the flow-file omits the top-level root_dir key when the caller did not supply one - - Field order is fixed: prompt, root_dir, name, description, stages — - matches the canonical emission order in `serialize_flow` + - memory defaults to None; field order is fixed: prompt, root_dir, name, + description, memory, stages — matches the canonical emission order + in `serialize_flow` properties: "prompt -> str | None": | @@ -691,6 +806,8 @@ Annotations: | Top-level flow name. "description -> str": | Top-level flow description. + "memory -> FlowMemory | None": | + The compiled memory block, or None when memory does not participate. "stages -> list[FlowStage]": | Ordered list of flow stages. @@ -801,22 +918,32 @@ Annotations: | top-level key (after prompt when present, before name), as a plain scalar. When `doc` root_dir is None — omit the key entirely (no top-level root_dir key in the output) + - when `doc` memory is not None — emit the memory block after + description and before stages, key order path, mode, memory_use, + max_rules, commit; every present value a plain scalar; a None + field omitted entirely (no key in the output). When `doc` memory + is None — omit the block entirely - name, then description, then stages (per step 2) 2. For each `FlowStage` in `doc`, build a representation in canonical key order: id, name, then the stage fields as-is (already in canonical order — interactive, auto_approve, auto_run, command, - prompt, description, agents, supervisor, supervisor_prompt, skills, - script_before, script, script_after, script_timeout, then - alphabetically-sorted unknown keys), then depends_on when it is not - None. The serializer does NOT reorder — canonical order is fixed at - `FlowStage` assembly + prompt, description, buttons, agents, supervisor, supervisor_prompt, + skills, script_before, script, script_after, script_timeout, reflect, + memory_use, then alphabetically-sorted unknown keys), then depends_on + when it is not None. The serializer does NOT reorder — canonical + order is fixed at `FlowStage` assembly. Stage memory keys: reflect — + a block-style mapping of plain scalars (file, mode); memory_use — a + plain bool scalar 3. Serialize the representation to YAML per `beautiful_yaml`, with the flow-style applied to agents and block-style applied to skills, depends_on, and the top-level prompt; auto_approve and auto_run as plain bool scalars; script_before / script / script_after as plain scalars when single-line and block-literal scalars when multi-line; script_timeout as a plain scalar when single-line and a block-literal - scalar when multi-line (the script-family pattern) + scalar when multi-line (the script-family pattern); buttons values: + single-line text — a plain scalar (quoted as needed), multi-line text + — a block-literal scalar; the buttons map itself is emitted as a + regular block-style mapping 4. Ensure the result ends with a single trailing newline 5. Return the string @@ -826,9 +953,11 @@ Annotations: | Requirements: - Output is canonical afm flow-file YAML: optional prompt (first, when present), optional root_dir (second, when supplied), then - fixed top-level key order (name, description, stages), canonical + fixed top-level key order (name, description, memory (when present), + stages), canonical per-stage key order (including auto_approve, auto_run, command, - description, and script_before/script/script_after/script_timeout), + description, buttons, script_before/script/script_after/script_timeout, + reflect, and memory_use), flow-style for agents, block-style for skills and depends_on, auto_approve and auto_run as plain bool scalars, single trailing newline - When `doc` prompt is None — the output omits the prompt key @@ -847,9 +976,16 @@ Annotations: | auto_run serializes only as auto_run: false - a stage without an authored timeout serializes without the script_timeout key + - buttons values: single-line text as a plain scalar (quoted as needed), + multi-line text as a block-literal scalar; a stage without a buttons key + serializes without it (byte-identical output for notes-free pipelines) + - A flow document without memory serializes without the block and without + any memory stage key — byte-identical output for memory-free workflows + - The block occupies the position between description and stages; the stage + keys occupy their canonical slots Constraints: - - Do not reorder keys — the fields order is the caller's responsibility + - Do not reorder keys — canonical order is fixed at assembly - Do not validate `doc` — assume it is well-formed (`compile_flow` constructed it) - Do not write to disk — return the string; the caller writes it @@ -1091,6 +1227,13 @@ Annotations: | error "manual: false on non-manual stage <NAME>" The rewrite targets the working body copy only — the original parsed body and PipelineDocument stay untouched + - effective notes = the stages-block notes for this name (None + when the entry provides no notes key or the name has no + entry). The extend-seeded branch carries notes=None — notes + has no inline extend equivalent (notes in an extend-entry is + rejected by the workflow parser). An overlay merge passes the + notes field explicitly — an overlay that omitted it would + silently drop the instruction 4.6. Build a new ordered list of steps with loop-expansion applied — expands an extend-stage when the effective loop for that name is >= 2 (producing NAME-1..N): @@ -1143,6 +1286,22 @@ Annotations: | copy — no explicit rewrite needed for PHASES. 4.8. Replace the body's step list with the new (embedded + expanded + rewritten) sequence. + 4.9. Compute memory participation from the workflow (when `workflow` + is not None). The effective memory configuration is the + `WorkflowMemory` value of the workflow document when it carries + one, else a default-constructed `WorkflowMemory` (its field + defaults ARE the materialized authoring defaults: path None, + max_rules 25, commit False, mode None — the default method is + "reflect"). The method is the materialized method of the + effective configuration; under the reflect method — a stage + participates when its `WorkflowReflect` instruction is not None; + under the alignment method — a stage participates when its memory + instruction is True. Participation is computed over the stages + present in the working body after skip removal and loop expansion + (embedded extend-stages included; a stage removed by skip — + workflow skip OR the CLI GOGA_SKIP_STAGES channel — never + counts, so a run whose every participating stage was skipped + emits no block) 5. Build FlowStages from the (possibly reconstructed) body: - PHASES: for each `PhaseStep`, build a `FlowStage` with depends_on derived from list position (first step has none; each subsequent @@ -1159,6 +1318,18 @@ Annotations: | prompt/skills in stage <name>" (every stage, incl. loop-expanded copies; same pass as the agents/interactive-forbidden checks). before_script/after_script do NOT trigger the error + - In both branches, BEFORE assembling fields, check the + authoring-buttons prohibition: an authoring buttons key in a stage + body (pipeline-file stage OR embedded extend-stage) raises a + structural error "buttons key is forbidden in stage body; use notes + in workflow.stages" (same pass as the agents/interactive-forbidden + checks) + - Stage-body validation (in the existing stage-body checking pass): + a reflect or memory_use key in a stage body (pipeline-file stage OR + embedded extend-stage body) raises a structural error "reflect key + is forbidden in stage body; use reflect in workflow.stages" / + "memory_use key is forbidden in stage body; use memory in + workflow.stages" - In both branches, BEFORE canonical ordering, inject default stage fields when the source step body has no usable roles value (missing key, explicit null, or empty list): set agents to @@ -1231,12 +1402,28 @@ Annotations: | emitted. The authoring trigger key is consumed by the translation — it never reaches the output as an unknown key. Uniform across every loop-expanded copy + - Workflow notes translation: when the effective notes for the stage is + not None — assemble buttons (the map verbatim, keys and values + unchanged) into the fields canonical slot immediately after + description; uniform across every loop-expanded copy. A stage without + an effective notes assembles NO buttons key. Output-side only — the + source bodies and PipelineDocument stay untouched + - Stage memory keys assembly (uniform across every loop-expanded copy; + a skipped stage never reaches here): when the block is emitted and + the method is reflect — a stage carrying a `WorkflowReflect` + instruction assembles the reflect field (the authored file verbatim, + the materialized mode) into the canonical slot after script_timeout; + when the block is emitted and the method is alignment — every stage + assembles memory_use into the canonical slot after reflect: True on + a participating stage, explicit False on every non-participating one; + when the block is not emitted — no stage carries any memory key - In both branches, assemble the fields of each `FlowStage` in the EXTENDED canonical key order (interactive, auto_approve, auto_run, - command, prompt, description, agents, supervisor, supervisor_prompt, - skills, script_before, script, script_after, script_timeout, then - alphabetically-sorted unknown keys), copying from the - (defaults-injected) step body. + command, prompt, description, buttons, agents, supervisor, + supervisor_prompt, skills, script_before, script, script_after, + script_timeout, reflect, memory_use, then alphabetically-sorted + unknown keys), copying + from the (defaults-injected) step body. auto_approve (bool) is present only when the effective approve directive drives the roles effect ("auto"/"dialog") + planner-in-roles fired; script_before / script / script_after / script_timeout (str) @@ -1255,6 +1442,20 @@ Annotations: | `project_name` is not None, else the header description unchanged (OUTPUT-only — PipelineDocument.description stays the faithful mirror, like root_dir) + - memory = the built memory block (only when participation exists), + sourcing every value from the effective memory configuration of + step 4.9 (the `WorkflowMemory` value of the workflow document when + it carries one, else a default-constructed `WorkflowMemory` — its + field defaults ARE the materialized authoring defaults): path = the + fixed root ".goga/memory" joined with the authored suffix (the bare + root when the suffix is None); reflect method — mode "r", + memory_use False; alignment method — mode the materialized authored + value ("rw" by default), memory_use False; max_rules and commit + carried from the effective + configuration (25 / False when no block was authored); place the + block between description and stages of the `FlowDocument`. When + participation does not exist — memory is None (no block, no stage + keys) - name from the header; stages from the assembled FlowStages 7. Build `PipelineDocument` from (header, format, ORIGINAL body) — the parsed representation carried alongside the FlowDocument for the @@ -1293,7 +1494,9 @@ Annotations: | injected INTO the body at step 4.5 before this assembly, so a single body dict is the sole source for fields. Pipeline-file fields and workflow-injected fields coexist in the same dict without - merging or collision + merging or collision. The ONE exception is buttons: assembled from + the effective notes only, never threaded into the body (the + authoring-buttons prohibition closes that channel) - Unknown stage names in workflow.stages (names absent from both the original body and the extend-stages) raise a structural error "unknown stage name in workflow.stages: <name>"; @@ -1421,9 +1624,9 @@ Annotations: | - script together with prompt and/or skills is a structural error "script is mutually exclusive with prompt/skills in stage <name>"; before_script/after_script are compatible (no error) - - auto_approve, script_before, script, and script_after appear only when - their source directive is present; flow-files without those directives - carry none of these keys + - auto_approve, buttons, script_before, script, and script_after appear + only when their source directive is present; flow-files without those + directives carry none of these keys - trigger (when present in a stage body or extend body) must be exactly on_success or manual; any other value (including on_failure) is a structural error "trigger must be one of: on_success, manual" @@ -1451,6 +1654,34 @@ Annotations: | - script_timeout occupies the canonical fields slot immediately after script_after — a pipeline without timeout compiles byte-identically (no script_timeout key anywhere) + - buttons is authored ONLY via the workflow stages-block notes instruction; + an authoring buttons key in a stage body (pipeline-file stage or + extend-stage body) is a structural error raised by `compile_flow` + - a non-None effective notes assembles the stage's buttons field (the map + verbatim) in the canonical slot immediately after description; every + loop-expanded copy carries the same buttons + - a stage without an effective notes assembles no buttons key — a pipeline + without notes compiles byte-identically (no buttons key anywhere) + - notes on an extend-stage name applies (embedded before validation); a + name absent from both the original body and the extend-stages raises the + existing structural error "unknown stage name in workflow.stages: <name>" + - buttons is output-side only — `PipelineDocument` and the source bodies + are never affected + - The block is emitted if and only if at least one stage participates — a + memory configuration without participation is a silent no-op (no block, + no stage keys, not even an opting-out stage key) + - All six emission cases hold: no block + no instructions → no block; no + block + reflect instructions → block; block without instructions → no + block; alignment + a true memory instruction → block; alignment without + a true memory instruction → no block; reflect + a reflect instruction → + block + - The emitted path is the fixed memory root joined with the authored suffix + - Participation is counted over the working body, not over the + workflow.stages map — a skipped stage's instructions do not count + - Every loop-expanded copy carries the same memory keys as its original + - The method selector never appears in the output + - A workflow without memory participation compiles byte-identically — + no memory block, no stage keys Constraints: - Do not read AFM_DIR or any environment variable — `flow_path` is @@ -1534,9 +1765,9 @@ Annotations: | `FlowStage` field only - Canonical key order is fixed at `FlowStage` assembly — the full order (interactive, auto_approve, auto_run, command, prompt, - description, agents, supervisor, supervisor_prompt, skills, - script_before, script, script_after, script_timeout, then - alphabetically-sorted unknown keys) + description, buttons, agents, supervisor, supervisor_prompt, skills, + script_before, script, script_after, script_timeout, reflect, + memory_use, then alphabetically-sorted unknown keys) - Do not leak auto_approve or script_* into `PipelineDocument` — they are output-side `FlowStage` fields only - Do not emit auto_run: true — the afm contract treats the key's absence @@ -1548,6 +1779,26 @@ Annotations: | - Do not mutate the original parsed body when applying manual — the rewrite lives on the working copy; PipelineDocument carries the source bodies + - Do not interpret the buttons — the runtime meaning belongs to afm; the + compiler only assembles and serializes the field + - Do not let an authoring buttons key pass through to output as an unknown + key — it is rejected as a structural error + - Do not apply notes to a skipped stage — skip removal runs before the + override pass + - Do not thread the effective notes into the (working) stage body — unlike + the command/description overrides, buttons are assembled into `FlowStage` + fields directly from the effective-notes value; the body channel is closed + by the authoring-buttons prohibition (an injected key would trip the + step-5 check) + - Do not re-validate the authoring vocabulary — the workflow parser already + rejected non-conforming authoring (per `memory`) + - Do not interpret the memory keys — the runtime behavior belongs to afm + (per `afm`) + - Do not emit memory stage keys when the block is absent + - Do not leak the memory block or the stage keys into `PipelineDocument` — + output-side only + - Do not apply memory instructions to a skipped stage — skip removal runs + before the assembly "translate_role(role: str) -> name: str": location: compile_flow.py diff --git a/goga/pipeline/compiler/__init__.py b/goga/pipeline/compiler/__init__.py index 9df36f6d..4977eee9 100644 --- a/goga/pipeline/compiler/__init__.py +++ b/goga/pipeline/compiler/__init__.py @@ -1,12 +1,13 @@ """Compiler cell — pure transformer from goga DSL pipeline-files to afm flow-files. Built incrementally: each entity task adds its module's import and ``__all__`` -entry. Once all entity tasks land, all 15 contract names are re-exported here. +entry. Once all entity tasks land, all 16 contract names are re-exported here. """ from .body_format import BodyFormat from .compile_flow import compile_flow, translate_role from .flow_document import FlowDocument +from .flow_memory import FlowMemory from .flow_stage import FlowStage from .parse_dsl import StructuralError, parse_dsl from .phase_step import PhaseStep @@ -21,6 +22,7 @@ __all__: list[str] = [ "BodyFormat", "FlowDocument", + "FlowMemory", "FlowStage", "PhaseStep", "PhasesBody", diff --git a/goga/pipeline/compiler/compile_flow.py b/goga/pipeline/compiler/compile_flow.py index fe9e7bb1..d2fc5281 100644 --- a/goga/pipeline/compiler/compile_flow.py +++ b/goga/pipeline/compiler/compile_flow.py @@ -61,6 +61,56 @@ loop-expanded copy (``NAME-i``) inherits the translated value verbatim. The translation is local to ``FlowStage`` assembly — the ``PipelineDocument.body`` returned to consumers keeps the authored ``timeout`` untouched. + +Symmetrically, the workflow ``notes`` instruction compiles into the per-stage +``buttons`` field: when the workflow stages block carries notes for a stage +(a map of note name → prompt text on ``WorkflowStage``), the compiler +assembles the stage's ``buttons`` field in the canonical slot immediately +after ``description``; the map passes through verbatim — keys and values +unchanged. An authoring ``buttons`` key in a stage body (pipeline-file stage +OR embedded extend-stage) is a structural error ``"buttons key is forbidden +in stage body; use notes in workflow.stages"`` — buttons are authored ONLY +through the workflow notes instruction (a single authoring source; no +collisions). The instruction applies per stage name to embedded extend-stages +as well; loop-expanded copies carry the same buttons; a skipped stage never +reaches the application (skip removal runs first). Interpretation of the +buttons belongs to afm — the compiler only assembles and serializes the +field. Output-side only — the ``PipelineDocument.body`` returned to consumers +stays the faithful mirror of the source pipeline-file. + +Symmetrically, the workflow memory instructions compile into the top-level +``memory`` block and the per-stage memory keys. Step 4.9 +(``_memory_emission``) resolves the effective memory configuration — the +workflow document's ``memory`` block, else a default-constructed +``WorkflowMemory()`` whose field defaults ARE the materialized authoring +defaults — and counts participation over the working body (after skip removal +and loop expansion, embedded extend-stages included; a skipped stage's +instructions never count): under the reflect method a stage participates by +carrying a ``reflect`` instruction, under the alignment method by carrying a +true ``memory`` instruction. The block is emitted if and only if at least one +stage participates — a memory configuration alone is a silent no-op (no +block, no stage keys, not even an opting-out stage key). The emitted block +composes the fixed memory root ``.goga/memory`` with the authored suffix +(the bare root when the suffix is ``None``); the reflect method emits the +fixed ``mode: r`` and ``memory_use: false`` (read-only project memory, no +global participation), the alignment method emits the materialized ``mode`` +(the authored value, ``rw`` by default) and ``memory_use: false``; +``max_rules`` +and ``commit`` carry from the effective configuration. The stage keys occupy +the canonical slots immediately after ``script_timeout``: under reflect a +participating stage carries ``reflect: {file, mode}`` (file verbatim, mode +materialized); under alignment EVERY stage carries ``memory_use`` — true on a +participating stage, an explicit false on every non-participating one (afm's +``UseFor(stage)`` inherits the global default for an unset key, so the +compiler never leaves one unset). Both are uniform across every loop-expanded +copy. The goga-side method selector never reaches the output. An authoring +``reflect`` / ``memory_use`` key in a stage body (pipeline-file stage OR +embedded extend-stage body) is a structural error — the memory stage keys are +compiled exclusively from the workflow instructions. The authoring vocabulary +is NOT re-validated here — ``parse_workflow`` already rejected non-conforming +authoring. Interpretation of the memory keys belongs to afm — the compiler +only assembles and serializes them. Output-side only — nothing memory-side +leaks into the ``PipelineDocument.body`` returned to consumers. ``auto`` is a sentinel string emitted verbatim (goga does not interpret it; afm resolves the agent). In a body carrying ``script``, the ``agents`` directive is NOT @@ -94,12 +144,14 @@ import copy import logging +from dataclasses import dataclass from pathlib import Path from typing import Any -from ..workflow import WorkflowDocument, WorkflowExtendStage, WorkflowStage +from ..workflow import WorkflowDocument, WorkflowExtendStage, WorkflowMemory, WorkflowStage from .body_format import BodyFormat from .flow_document import FlowDocument +from .flow_memory import FlowMemory from .flow_stage import FlowStage from .parse_dsl import StructuralError, parse_dsl from .phase_step import PhaseStep @@ -129,6 +181,20 @@ # ``script_timeout`` (str) sits immediately after ``script_after`` — the # translated form of the authoring ``timeout`` directive — and is likewise # present only when authored (or directly authored under its output name). +# ``buttons`` (map of str→str) sits immediately after ``description`` — the +# compiled form of the workflow ``notes`` instruction (the map verbatim, one +# deep copy per ``FlowStage``); present only when the workflow supplied a +# non-empty notes instruction for the stage, so pipelines without notes +# compile byte-identically. +# ``reflect`` (map of file + mode) and ``memory_use`` (bool) close the list — +# the compiled form of the workflow memory instructions. ``reflect`` is present +# only when the memory block is emitted and the stage's reflect instruction is +# effective (the authored file verbatim, the materialized mode), uniform across +# every loop-expanded copy. ``memory_use`` is present only when the block is +# emitted under the alignment method — ``True`` on a participating stage, an +# explicit ``False`` on every non-participating one. A stage of a memory-free +# workflow carries neither key, so pipelines without memory participation +# compile byte-identically. _CANONICAL_KEY_ORDER = [ "interactive", "auto_approve", @@ -136,6 +202,7 @@ "command", "prompt", "description", + "buttons", "agents", "supervisor", "supervisor_prompt", @@ -144,8 +211,17 @@ "script", "script_after", "script_timeout", + "reflect", + "memory_use", ] +# The fixed project-memory root of the emitted memory block. The authored +# ``memory.path`` suffix is joined onto this root (``.goga/memory/<suffix>``); +# a ``None`` suffix emits the bare root. The root is fixed here — the workflow +# cell carries the authored suffix only, and this cell composes the final path +# (the single composition site). +_MEMORY_ROOT = ".goga/memory" + # Sentinel key threaded by ``_apply_per_stage_overrides`` into a reconstructed # step body to carry the effective ``approve`` directive through loop-expansion # (it survives ``copy.deepcopy`` in ``_expand_loops``/``_make_expanded_copy``) @@ -303,11 +379,17 @@ def _inject_defaults(body: dict[str, Any], suppress_agents: bool = False) -> dic def _reject_authoring_output_keys(body: dict[str, Any]) -> None: """Reject authoring-side stage-body keys that duplicate output-only afm fields. - Three authoring keys are forbidden because each names an afm OUTPUT field + Six authoring keys are forbidden because each names an afm OUTPUT field whose authoring-side counterpart is a different key: ``agents`` (author the ``roles`` field — translated element-wise), ``interactive`` (author the - ``communication`` field — renamed), and ``auto_run`` (author the ``trigger`` - field — ``trigger: manual`` assembles ``auto_run: false``). Checking them in + ``communication`` field — renamed), ``auto_run`` (author the ``trigger`` + field — ``trigger: manual`` assembles ``auto_run: false``), ``buttons`` + (author the workflow ``notes`` instruction — a different FILE, not a + different body key: buttons live in the workflow-file stages block, never + in a stage body), and the two memory keys ``reflect`` / ``memory_use`` + (author the workflow ``reflect`` / ``memory`` instructions — the memory + stage keys are compiled exclusively from the workflow instructions, never + from a stage body). Checking them in one place, at the very start of ``_canonical_fields``, keeps every prohibition ahead of any translation, exactly as the contract orders it. @@ -316,7 +398,7 @@ def _reject_authoring_output_keys(body: dict[str, Any]) -> None: extend body). Raises: - StructuralError: When ``body`` carries any of the three authoring keys, + StructuralError: When ``body`` carries any of the six authoring keys, with the contract message naming the authoring-side field to use. """ if "agents" in body: @@ -328,6 +410,15 @@ def _reject_authoring_output_keys(body: dict[str, Any]) -> None: if "auto_run" in body: raise StructuralError("auto_run key is forbidden in stage body; use trigger: manual") + if "buttons" in body: + raise StructuralError("buttons key is forbidden in stage body; use notes in workflow.stages") + + if "reflect" in body: + raise StructuralError("reflect key is forbidden in stage body; use reflect in workflow.stages") + + if "memory_use" in body: + raise StructuralError("memory_use key is forbidden in stage body; use memory in workflow.stages") + def _validate_trigger(body: dict[str, Any]) -> str | None: """Return the effective ``trigger`` of ``body``, validating the closed value set. @@ -394,7 +485,66 @@ def _apply_timeout_directive(body: dict[str, Any], stage_name: str, timeout_valu body["script_timeout"] = timeout_value -def _canonical_fields(body: dict[str, Any], stage_name: str) -> dict[str, Any]: +def _assemble_buttons(source: dict[str, Any], notes: dict[str, str] | None) -> None: + """Deep-copy the effective workflow notes into the output ``buttons`` field. + + The single assembly site of the workflow notes instruction: a + non-``None`` notes map is deep-copied verbatim — keys and values + unchanged — into the REBUILT source dict under the output ``buttons`` key + (the canonical-order loop in ``_canonical_fields`` slots it immediately + after ``description``). The notes value travels as a function argument, + never threaded through the stage body (the authoring-buttons prohibition + in ``_reject_authoring_output_keys`` guards that channel), and the deep + copy keeps ``FlowStage.fields["buttons"]`` from aliasing + ``WorkflowStage.notes`` — post-compile mutation of either side cannot + corrupt the other. ``None`` (no instruction, or an empty map normalized + to ``None`` by ``parse_workflow``) assembles no key at all. + + Args: + source: The REBUILT body dict (authoring keys already translated) — + mutated in place by the assignment (the caller's fresh dict, never + the caller's original parsed body). + notes: The effective workflow notes instruction for the stage, or + ``None`` when the workflow carries none. Read-only input. + """ + if notes is not None: + source["buttons"] = copy.deepcopy(notes) + + +def _assemble_memory_keys(source: dict[str, Any], memory_fields: dict[str, Any] | None) -> None: + """Assign the stage's computed memory keys into the output fields dict. + + The single assembly site of the stage memory keys (by the + ``_assemble_buttons`` precedent, but a plain assignment — the values are + fresh dictionaries/scalars built per call by ``_memory_emission``, so no + deep copy is needed; strings are immutable). A non-empty ``memory_fields`` + map updates the REBUILT source dict in place (the canonical-order loop in + ``_canonical_fields`` then slots ``reflect`` / ``memory_use`` into their + slots immediately after ``script_timeout``). The memory value travels as a + function argument, never threaded through the stage body (the + authoring prohibition in ``_reject_authoring_output_keys`` guards that + channel). ``None`` or an empty map — a non-participating stage, or any + stage when the memory block is absent — assembles no key at all. + + Args: + source: The REBUILT body dict (authoring keys already translated) — + mutated in place by the assignment (the caller's fresh dict, never + the caller's original parsed body). + memory_fields: The computed memory keys for this stage (a + ``{"reflect": {...}}` or ``{"memory_use": bool}`` map from + ``_memory_emission``), or ``None``/empty when the stage carries + none. Read-only input. + """ + if memory_fields: + source.update(memory_fields) + + +def _canonical_fields( + body: dict[str, Any], + stage_name: str, + notes: dict[str, str] | None = None, + memory_fields: dict[str, Any] | None = None, +) -> dict[str, Any]: """Reorder ``body`` into canonical key order, deep-copying each value. A legacy ``agents`` key in the step body is rejected up front with @@ -406,7 +556,18 @@ def _canonical_fields(body: dict[str, Any], stage_name: str) -> dict[str, Any]: is the output-only afm field. Likewise, an authoring ``auto_run`` key is rejected with ``StructuralError("auto_run key is forbidden in stage body; use trigger: manual")`` — the authoring-side field for the launch mode is - ``trigger``; ``auto_run`` is the output-only afm field. + ``trigger``; ``auto_run`` is the output-only afm field. Likewise, an + authoring ``buttons`` key is rejected with + ``StructuralError("buttons key is forbidden in stage body; use notes in + workflow.stages")`` — buttons are authored ONLY through the workflow + ``notes`` instruction (a different FILE, not a different body key); + ``buttons`` is the output-only afm field. Likewise, an authoring + ``reflect`` key is rejected with ``StructuralError("reflect key is + forbidden in stage body; use reflect in workflow.stages")`` and an + authoring ``memory_use`` key with ``StructuralError("memory_use key is + forbidden in stage body; use memory in workflow.stages")`` — the memory + stage keys are compiled exclusively from the workflow memory instructions; + ``reflect`` / ``memory_use`` are output-only afm fields. The ``trigger`` key (when present in a pipeline-file body, an embedded extend body, or a loop-expanded copy) is read and validated: any non-``None`` @@ -464,11 +625,33 @@ def _canonical_fields(body: dict[str, Any], stage_name: str) -> dict[str, Any]: ``interactive``). A body whose effective ``trigger`` is ``manual`` assembles ``auto_run: false`` (canonical slot immediately after ``auto_approve``); ``trigger: on_success`` or no trigger assembles NO ``auto_run`` key — - ``auto_run: true`` is never emitted. Known keys (``interactive``, + ``auto_run: true`` is never emitted. The ``notes`` argument (the effective + workflow notes instruction for this stage, already resolved by base name + so loop-expanded copies share it) is assembled into the output ``buttons`` + field whenever it is not ``None``: the map is deep-copied verbatim — keys + and values unchanged — into the canonical slot immediately after + ``description``. The notes value travels as a function argument ONLY; it + is never threaded into ``body`` under any key (a body ``buttons`` key + trips the authoring-buttons prohibition above), keeping the + single-authoring-source rule intact. A ``None`` notes (no instruction, or + an empty map normalized to ``None`` upstream) assembles no ``buttons`` key + at all. The ``memory_fields`` argument (the computed memory keys for this + stage, resolved by FINAL id so loop-expanded copies share them) is + assembled into the output by ``_assemble_memory_keys`` whenever it is + non-empty: under the reflect method a participating stage carries + ``reflect: {file, mode}``; under the alignment method EVERY stage carries + ``memory_use`` (an explicit ``False`` on every non-participating one). The + memory value travels as a function argument ONLY — never threaded into + ``body`` under any key (a body ``reflect``/``memory_use`` key trips the + authoring prohibition above); ``None`` or empty assembles no key at all + (a memory-free stage of a block-less workflow carries neither key, so + memory-free pipelines compile byte-identically). Known keys + (``interactive``, ``auto_approve``, ``auto_run``, ``command``, - ``prompt``, ``description``, ``agents``, ``supervisor``, ``supervisor_prompt``, + ``prompt``, ``description``, ``buttons``, ``agents``, ``supervisor``, + ``supervisor_prompt``, ``skills``, ``script_before``, ``script``, ``script_after``, - ``script_timeout``) are emitted in + ``script_timeout``, ``reflect``, ``memory_use``) are emitted in that fixed order; any remaining keys are appended alphabetically. The input-only ``roles`` key never reaches the output (dropped in ``_inject_defaults``); the input-only ``communication`` key never reaches the @@ -488,6 +671,18 @@ def _canonical_fields(body: dict[str, Any], stage_name: str) -> dict[str, Any]: reconstructed deep copy carrying the ``_APPROVE_SENTINEL``). stage_name: The stage id (used in the mutual-exclusion error message — for loop-expanded copies this is ``NAME-i``). + notes: The effective workflow notes instruction for this stage (a map + of note name → prompt text, already resolved by base name so + loop-expanded copies share it), or ``None`` when the workflow + carries none. Read-only input — deep-copied into the assembled + ``buttons`` field, never threaded into ``body``. + memory_fields: The computed memory keys for this stage (a + ``{"reflect": {"file": ..., "mode": ...}}`` map under the reflect + method, or a ``{"memory_use": bool}`` map under alignment, already + resolved by final id so loop-expanded copies share them), or + ``None``/empty when the stage carries none. Read-only input — + assigned into the assembled fields by ``_assemble_memory_keys``, + never threaded into ``body``. Returns: A new dict in canonical key order with deep-copied values. @@ -499,7 +694,13 @@ def _canonical_fields(body: dict[str, Any], stage_name: str) -> dict[str, Any]: field is ``communication``; ``interactive`` is output-only. Or if ``body`` carries an authoring ``auto_run`` key — the authoring-side field for the launch mode is ``trigger``; ``auto_run`` is - output-only. Or if ``body`` carries a ``trigger`` value outside the + output-only. Or if ``body`` carries an authoring ``buttons`` key — + buttons are authored ONLY through the workflow ``notes`` + instruction; ``buttons`` is output-only. Or if ``body`` carries an + authoring ``reflect``/``memory_use`` key — the memory stage keys are + authored ONLY through the workflow memory instructions; + both are output-only. Or if ``body`` carries a + ``trigger`` value outside the closed set ``on_success``/``manual`` (a ``None`` value counts as absent). Or if ``body`` carries ``script`` together with ``prompt`` and/or ``skills`` @@ -580,6 +781,22 @@ def _canonical_fields(body: dict[str, Any], stage_name: str) -> dict[str, Any]: # ``script`` is legal; the agents slot is simply not emitted). source = _inject_defaults(body, suppress_agents="script" in body) + # Workflow notes instruction → the output ``buttons`` field (single + # authoring source). The notes map arrives as a function argument — never + # threaded through the body (the authoring-buttons prohibition above would + # trip on a body ``buttons`` key) — and is deep-copied into the assembled + # fields by ``_assemble_buttons`` (see its docstring for the no-aliasing + # rationale). The ``notes`` argument itself is read-only input; this + # function stays non-mutating. + _assemble_buttons(source, notes) + + # Computed stage memory keys (step 4.9). The memory value travels as a + # function argument — never threaded through the body (the authoring + # prohibition above would trip on a body ``reflect``/``memory_use`` key) — + # and is assigned by ``_assemble_memory_keys`` BEFORE the canonical-order + # loop so both keys land in their canonical slots after ``script_timeout``. + _assemble_memory_keys(source, memory_fields) + # An approve directive that drives the roles effect (``auto``/``dialog``) + # ``planner`` in the raw roles ⇒ emit ``auto_approve: true`` (canonical slot # right after ``interactive``). The two approve effects are independent: @@ -621,21 +838,36 @@ def _effective_overrides(workflow: WorkflowDocument) -> dict[str, WorkflowStage] ``manual`` (``parse_workflow`` rejects it in an extend-entry), so the merged branch passes ``manual=stg.manual`` EXPLICITLY — the ``WorkflowStage`` constructor defaults it to ``None``, and an overlay that omitted it would - silently drop the instruction. + silently drop the instruction. The note-buttons instruction is + stages-block-only the same way: the extend seed carries ``notes=None`` + (the constructor default — ``parse_workflow`` rejects ``notes`` in an + extend-entry), and the merged branch passes ``notes=stg.notes`` + explicitly, mirroring ``manual``. The two memory-participation + instructions ``reflect``/``memory`` are stages-block-only the same way too + (``parse_workflow`` rejects both in an extend-entry), so the merged branch + passes ``reflect=stg.reflect, memory=stg.memory`` explicitly — an overlay + that relied on the constructor default would silently drop the + participation instruction of an extend-stage named in both ``extend`` and + ``stages``. Args: workflow: The declarative workflow instructions. Returns: The effective per-stage override map keyed by stage name. Extend-seeded - entries carry only ``agent``/``loop``/``approve`` (``manual`` stays - ``None``); stages-block entries carry their full ``WorkflowStage``; - merged entries combine them per-field, always carrying the stages-block - ``manual``. + entries carry only ``agent``/``loop``/``approve`` (``manual``, + ``notes``, ``reflect``, and ``memory`` stay ``None``); stages-block + entries carry their full ``WorkflowStage``; merged entries combine them + per-field, always carrying the stages-block ``manual``, ``notes``, + ``reflect``, and ``memory``. """ effective: dict[str, WorkflowStage] = {} for name, ext in workflow.extend.items(): + # Extend-seeded default: only the inline fields an extend-entry can + # carry. ``manual``/``notes``/``reflect``/``memory`` stay ``None`` (the + # constructor defaults) — all four are stages-block-only + # (``parse_workflow`` rejects them in an extend-entry). effective[name] = WorkflowStage(agent=ext.agent, loop=ext.loop, approve=ext.approve) for name, stg in workflow.stages.items(): @@ -645,7 +877,8 @@ def _effective_overrides(workflow: WorkflowDocument) -> dict[str, WorkflowStage] effective[name] = stg continue # Per-field overlay: stages-block wins whenever its field is not None. - # ``manual`` is passed explicitly — the extend seed carries none, and the + # ``manual``, ``notes``, ``reflect``, and ``memory`` are passed + # explicitly — the extend seed carries none of them, and the # constructor default (None) would silently drop the instruction. effective[name] = WorkflowStage( agent=stg.agent if stg.agent is not None else base.agent, @@ -654,11 +887,182 @@ def _effective_overrides(workflow: WorkflowDocument) -> dict[str, WorkflowStage] skills=stg.skills, approve=stg.approve if stg.approve is not None else base.approve, manual=stg.manual, + notes=stg.notes, + reflect=stg.reflect, + memory=stg.memory, ) return effective +def _effective_notes_by_id( + effective: dict[str, WorkflowStage], + expanded_ids: dict[str, list[str]], +) -> dict[str, dict[str, str] | None]: + """Resolve the effective notes for every FINAL step id (base name → copies). + + Built from the resolved per-stage override map (``_effective_overrides``) + and the loop-expansion map (``_expand_loops``): each base name's effective + notes value is copied onto EVERY id that base produced. Resolution is by + BASE name on purpose — a loop-expanded copy's id (``NAME-i``) never appears + in ``workflow.stages``, so a naive ``effective.get(step.name)`` lookup on + the final id would return ``None`` for every copy and silently drop the + instruction; routing through ``expanded_ids`` keeps the buttons uniform + across all copies of one stage. + + ``expanded_ids`` covers EVERY final id by construction (``_expand_loops`` + maps a non-expanded base to ``[base]`` itself), so every final step id has + an entry in the result — a missing key is impossible. + + Args: + effective: The resolved per-stage override map (from + ``_effective_overrides``), keyed by stage name. + expanded_ids: The base-name → produced-ids map from ``_expand_loops`` + (every final id appears in exactly one produced-ids list). + + Returns: + The final-id → effective-notes map. A name absent from ``effective`` + (or carrying ``notes=None``) maps to ``None`` — no buttons key. + """ + notes_by_id: dict[str, dict[str, str] | None] = {} + + for base_name, produced_ids in expanded_ids.items(): + stage = effective.get(base_name) + notes = stage.notes if stage is not None else None + + for produced_id in produced_ids: + notes_by_id[produced_id] = notes + + return notes_by_id + + +@dataclass(kw_only=True) +class _MemoryEmission: + """The step-4.9 result — the memory block plus the per-stage memory keys. + + ``block`` is the compiled top-level ``FlowMemory`` (``None`` when no stage + participates — the block is emitted if and only if participation exists). + ``keys_by_id`` maps every FINAL step id that carries a memory key to its + computed keys (``{"reflect": {"file": ..., "mode": ...}}`` under the + reflect method — only participating ids; ``{"memory_use": bool}`` under + alignment — every final id). Both values are built fresh per + ``_memory_emission`` call; the dataclass is private plumbing, never part + of the facade. + + Args: + block: The compiled memory block, or ``None`` when memory does not + participate. + keys_by_id: The final-id → memory-keys map consumed per step by + ``_canonical_fields`` (loop-expanded copies resolve through their + base name's produced ids). + """ + + block: FlowMemory | None + keys_by_id: dict[str, dict[str, Any]] + + +def _memory_emission( + workflow: WorkflowDocument | None, + effective: dict[str, WorkflowStage], + expanded_ids: dict[str, list[str]], +) -> _MemoryEmission: + """Step 4.9 — compute the memory block and the per-stage memory keys. + + The single place where block emission is decided and the stage keys are + computed. The effective memory configuration is the workflow document's + ``memory`` block when it carries one, else a default-constructed + ``WorkflowMemory()`` (its field defaults ARE the materialized authoring + defaults — path ``None``, ``max_rules`` 25, ``commit`` False, ``mode`` + ``None``; the default method is ``"reflect"``). Participation is computed + over the WORKING body — the stages present in ``expanded_ids``, i.e. after + skip removal and loop expansion, embedded extend-stages included; a stage + removed by skip never appears there, so its instructions never count. A + final id whose base has no workflow record (``effective.get(base)`` is + ``None``) never participates — the same guard as + ``_effective_notes_by_id``. + + Under the reflect method a stage participates when its ``reflect`` + instruction is not ``None``; under the alignment method when its ``memory`` + instruction is ``True``. With no participants the emission is a silent + no-op: no block, no stage keys, not even an opting-out stage key (a memory + configuration alone never turns the block on). + + The block composes ``_MEMORY_ROOT`` with the authored suffix (the bare + root when the suffix is ``None``); the reflect method emits the fixed + ``mode: "r"`` and ``memory_use: False`` (read-only project memory, no + global participation), the alignment method the materialized ``mode`` + (the authored value, ``"rw"`` by default) and ``memory_use: False`` + (participation is per-stage opt-in, never the global default); + ``max_rules``/``commit`` carry from the effective + configuration. The keys: under reflect every PARTICIPATING final id + carries ``{"reflect": {"file": ..., "mode": ...}}`` (the authored file + verbatim, the materialized mode); under alignment EVERY final id carries + ``{"memory_use": ...}`` — ``True`` for participants, an explicit ``False`` + for everyone else (afm's ``UseFor(stage)`` inherits the global default for + an unset key, so the compiler never leaves one unset). Every + loop-expanded copy of one base carries identical keys. The goga-side + method selector never reaches any output. + + The authoring vocabulary is NOT re-validated here — ``parse_workflow`` + already rejected non-conforming authoring. This helper only READS + ``effective``/``expanded_ids`` (no mutation) and builds fresh values, so + no deep copy is needed. + + Args: + workflow: The declarative workflow instructions, or ``None`` when no + workflow is applied (no block, no keys — byte-identical output). + effective: The resolved per-stage override map (from + ``_effective_overrides``), keyed by stage name. Read-only input. + expanded_ids: The base-name → produced-ids map from ``_expand_loops`` + over the working body (every final id appears in exactly one + produced-ids list). Read-only input. + + Returns: + The ``_MemoryEmission`` — the block (or ``None``) and the final-id → + memory-keys map. + """ + if workflow is None: + return _MemoryEmission(block=None, keys_by_id={}) + + config = workflow.memory if workflow.memory is not None else WorkflowMemory() + method = config.method + + participating_ids: set[str] = set() + + for base_name, produced_ids in expanded_ids.items(): + stage = effective.get(base_name) + instr_reflect = stage.reflect if stage is not None else None + instr_memory = (stage.memory is True) if stage is not None else False + participates = (instr_reflect is not None) if method == "reflect" else instr_memory + + if participates: + participating_ids.update(produced_ids) + + if not participating_ids: + return _MemoryEmission(block=None, keys_by_id={}) + + path = _MEMORY_ROOT if config.path is None else f"{_MEMORY_ROOT}/{config.path}" + block = FlowMemory( + path=path, + mode=("r" if method == "reflect" else config.mode), + memory_use=False, + max_rules=config.max_rules, + commit=config.commit, + ) + + keys_by_id: dict[str, dict[str, Any]] = {} + + for base_name, produced_ids in expanded_ids.items(): + for produced_id in produced_ids: + if method == "alignment": + keys_by_id[produced_id] = {"memory_use": produced_id in participating_ids} + elif produced_id in participating_ids: + reflect = effective[base_name].reflect + keys_by_id[produced_id] = {"reflect": {"file": reflect.file, "mode": reflect.mode}} + + return _MemoryEmission(block=block, keys_by_id=keys_by_id) + + def _merge_skills( pipeline_skills: list[str] | None, workflow_skills: list[str] | None, @@ -1375,7 +1779,7 @@ def _reconstruct_body( fmt: BodyFormat, body: PhasesBody | StagesBody, workflow: WorkflowDocument, -) -> list[PhaseStep | StageStep]: +) -> tuple[list[PhaseStep | StageStep], dict[str, dict[str, str] | None], _MemoryEmission]: """Apply the workflow reconstruction branch, returning a NEW step sequence. Deep-copies the parsed steps first so the ORIGINAL body (returned later via @@ -1389,18 +1793,31 @@ def _reconstruct_body( ``StructuralError("empty body")`` if nothing survives; resolve the effective per-stage override map ONCE (inline extend → stages overlay); (4a) per-stage overrides; (4b) loop-expansion with the expanded-ids map; (4c) external - depends_on rewrite (STAGES only); the result (4d) is the reconstructed step + depends_on rewrite (STAGES only); (4.9) compute the memory emission from + the working body; the result (4d) is the reconstructed step list consumed for ``FlowStage`` assembly. The mandatory - ``4a0-pre → 4a0 → 4pre → 4skip → empty-body guard → effective → 4a → 4b → 4c`` + ``4a0-pre → 4a0 → 4pre → 4skip → empty-body guard → effective → 4a → 4b → 4c → 4.9`` ordering is load-bearing: extend-ref validation runs before the 4a0 embed (extend names come from ``workflow.extend`` keys, so cross-references between extend-stages resolve without embedding); extend-stages must be in ``steps`` before strict validation (so an extend-embedded name is a valid ``workflow.stages`` target) and before the effective map is resolved (so their inline ``agent``/``loop`` seed the default override); skip removal runs - before ``4a`` so a skipped stage's overrides are never applied ("skip wins"); - the empty-body guard runs once on the working copy, format-agnostic, before - any assembly. + before ``4a`` so a skipped stage's overrides are never applied ("skip wins") + — and before the notes companion map and the memory emission are resolved, + so a skipped stage's notes and memory instructions can never leak into a + survivor; the empty-body guard runs once on the + working copy, format-agnostic, before any assembly. + + The return also carries the two companion values: the per-final-id + effective-notes map (built by ``_effective_notes_by_id`` from the effective + override map and the expanded-ids map) and the memory emission (built by + ``_memory_emission`` from the same inputs plus the workflow's memory + block). Both travel as SEPARATE maps — never threaded into the step bodies + — so the notes and memory instructions reach ``_canonical_fields`` as + function arguments while the working bodies stay free of any + buttons/notes/reflect/memory_use key (the authoring prohibitions would trip + on any of them). Args: fmt: The body format — PHASES or STAGES. @@ -1408,7 +1825,12 @@ def _reconstruct_body( workflow: The declarative workflow instructions. Returns: - The reconstructed step sequence (PHASES or STAGES steps). + The reconstructed step sequence (PHASES or STAGES steps), the + final-id → effective-notes map, and the ``_MemoryEmission`` (the + compiled memory block plus the final-id → memory-keys map). Both maps + are consumed per step by ``_canonical_fields`` (loop-expanded copies + resolve through their base name, keeping the keys uniform across + copies). Raises: StructuralError: When a ``workflow.extend.<name>.before/.after`` ref @@ -1430,7 +1852,11 @@ def _reconstruct_body( if fmt is BodyFormat.STAGES: _rewrite_external_depends_on(expanded, expanded_ids) - return expanded + return ( + expanded, + _effective_notes_by_id(effective, expanded_ids), + _memory_emission(workflow, effective, expanded_ids), + ) def compile_flow( @@ -1465,9 +1891,39 @@ def compile_flow( ``trigger: on_success`` or no trigger assembles NO ``auto_run`` key). ``supervisor``/``supervisor_prompt`` are authored-only — never injected, but they pass through the canonical slot when - the source body carries them. The translation/injection is local to + the source body carries them. When the ``workflow`` carries the per-stage + ``notes`` instruction, each stage's ``buttons`` field is assembled from it + (the map verbatim, canonical slot immediately after ``description``, + uniform across every loop-expanded copy and applied to embedded + extend-stages by name); an authoring ``buttons`` key in any stage body is + rejected with ``StructuralError`` — buttons are authored ONLY through the + workflow notes instruction. Symmetrically, step 4.9 computes the memory + emission: the effective memory configuration (the workflow's ``memory`` + block, else a default-constructed ``WorkflowMemory()``) plus participation + over the working body (a ``reflect`` instruction under the reflect method, + a true ``memory`` instruction under alignment; embedded extend-stages + included, skipped stages never counted). When at least one stage + participates, the top-level ``memory`` block is built (``path`` = the + fixed root ``.goga/memory`` joined with the authored suffix; reflect — + ``mode: r`` and ``memory_use: false``, alignment — the materialized + ``mode`` and + ``memory_use: false``; ``max_rules``/``commit`` from the configuration) and + placed between ``description`` and ``stages``, and the stage memory keys + are assembled into their canonical slots after ``script_timeout`` — + ``reflect: {file, mode}`` on participating stages under reflect, + ``memory_use`` (an explicit ``false`` on every non-participating stage) + on EVERY stage under alignment, uniform across loop-expanded copies. When + no stage participates the emission is a silent no-op — no block, no stage + keys — so a workflow without memory participation compiles + byte-identically to the current output. The goga-side method selector + never reaches the output, and the authoring vocabulary is not re-validated + here (``parse_workflow`` already rejected it). An authoring ``reflect`` / + ``memory_use`` key in any stage body is rejected with + ``StructuralError`` — the memory stage keys are authored ONLY through the + workflow memory instructions. The interpretation of the emitted memory + keys belongs to afm. The translation/injection is local to ``FlowStage.fields`` — the ``PipelineDocument.body`` returned to consumers is - never affected. + never affected (output-side only). When ``workflow`` is not ``None``, the parsed body is reconstructed (per-stage overrides, loop-expansion, external depends_on rewrite) on a deep @@ -1527,16 +1983,19 @@ def compile_flow( ``PipelineDocument`` carries the parsed header (with ``header.roles``), format, and the ORIGINAL body (always unprefixed); the ``FlowDocument`` carries the name, the optionally project-name-prefixed description, - optional top-level prompt, optional top-level ``root_dir``, and compiled - stages (the input ``roles`` field translated to the output ``agents`` - field). + optional top-level prompt, optional top-level ``root_dir``, the + optional compiled ``memory`` block (``None`` when memory does not + participate), and compiled stages (the input ``roles`` field + translated to the output ``agents`` field). Raises: StructuralError: On a structural defect in the DSL (propagated from ``parse_dsl``), on an empty body, on a legacy ``agents`` key in a stage body, on an authoring ``interactive``/``auto_run`` key in a stage body (the authoring-side fields are ``communication``/ - ``trigger``), or on a ``trigger`` value outside + ``trigger``), on an authoring ``reflect``/``memory_use`` key in a + stage body (the memory stage keys are authored through the workflow + memory instructions), or on a ``trigger`` value outside ``on_success``/``manual``. FileNotFoundError: If ``pipeline_path`` does not exist or ``flow_path``'s parent is missing (propagated). @@ -1552,15 +2011,31 @@ def compile_flow( # The step sequence consumed for FlowStage assembly. When a workflow is # applied, this is a reconstructed (deep-copied + overridden + expanded) - # sequence; the ORIGINAL `body` is preserved for PipelineDocument below. - reconstructed = _reconstruct_body(fmt, body, workflow) if workflow is not None else list(body.steps) + # sequence plus the two companion maps: the per-final-id effective-notes + # map (the source of each stage's ``buttons`` field) and the memory + # emission of step 4.9 (the compiled memory block plus the per-final-id + # memory keys — both resolved by base name / final id so loop-expanded + # copies share them); the ORIGINAL `body` is preserved for + # PipelineDocument below. Workflow-less compiles carry an empty notes map + # and an empty emission, so every lookup below is ``None``/falsy and no + # ``buttons``/``reflect``/``memory_use`` key is assembled. + if workflow is not None: + reconstructed, notes_by_id, memory_emission = _reconstruct_body(fmt, body, workflow) + else: + reconstructed, notes_by_id = list(body.steps), {} + memory_emission = _MemoryEmission(block=None, keys_by_id={}) stages: list[FlowStage] = [] if fmt is BodyFormat.PHASES: for i, step in enumerate(reconstructed): depends_on = [reconstructed[i - 1].name] if i > 0 else None - fields = _canonical_fields(step.body, step.name) + fields = _canonical_fields( + step.body, + step.name, + notes=notes_by_id.get(step.name), + memory_fields=memory_emission.keys_by_id.get(step.name), + ) stages.append( FlowStage( id=step.name, @@ -1571,7 +2046,12 @@ def compile_flow( ) elif fmt is BodyFormat.STAGES: for step in reconstructed: - fields = _canonical_fields(step.body, step.name) + fields = _canonical_fields( + step.body, + step.name, + notes=notes_by_id.get(step.name), + memory_fields=memory_emission.keys_by_id.get(step.name), + ) stages.append( FlowStage( id=step.name, @@ -1597,6 +2077,7 @@ def compile_flow( root_dir=root_dir, name=header.name, description=description, + memory=memory_emission.block, stages=stages, ) pipeline_doc = PipelineDocument(header=header, format=fmt, body=body) diff --git a/goga/pipeline/compiler/flow_document.py b/goga/pipeline/compiler/flow_document.py index 6bd27645..42d9ce15 100644 --- a/goga/pipeline/compiler/flow_document.py +++ b/goga/pipeline/compiler/flow_document.py @@ -1,26 +1,30 @@ """The ``FlowDocument`` dataclass — output afm flow-file model. -An afm flow-file is a single flat YAML document with up to five top-level keys -(prompt (when present), root_dir (when present), name, description, stages) — no -segmentation, no header sub-object. ``FlowDocument`` mirrors that flatness: it -carries the optional top-level ``prompt`` (populated from a workflow's prompt -when one is supplied, ``None`` otherwise), the optional top-level ``root_dir`` -(populated by the caller from the in-container project root — typically -``Path.cwd()`` inside the goga container, ``None`` otherwise), the carried 1:1 -``name`` and ``description`` from ``PipelineHeader``, and the ordered list of -``FlowStage`` items. It is the only object ``serialize_flow`` accepts as input. +An afm flow-file is a single flat YAML document with up to six top-level keys +(prompt (when present), root_dir (when supplied), name, description, memory +(when memory participates), stages) — no segmentation, no header sub-object. +``FlowDocument`` mirrors that flatness: it carries the optional top-level +``prompt`` (populated from a workflow's prompt when one is supplied, ``None`` +otherwise), the optional top-level ``root_dir`` (populated by the caller from +the in-container project root — typically ``Path.cwd()`` inside the goga +container, ``None`` otherwise), the carried 1:1 ``name`` and ``description`` +from ``PipelineHeader``, the optional compiled memory block (``FlowMemory``, +or ``None`` when memory does not participate), and the ordered list of +``FlowStage`` items. It is the only object ``serialize_flow`` accepts as +input. """ from __future__ import annotations from dataclasses import dataclass +from .flow_memory import FlowMemory from .flow_stage import FlowStage @dataclass(kw_only=True) class FlowDocument: - """Output afm flow-file — a flat document with up to five top-level keys. + """Output afm flow-file — a flat document with up to six top-level keys. Args: prompt: Top-level flow prompt, or ``None`` when no workflow supplied @@ -37,6 +41,10 @@ class FlowDocument: name: Top-level flow name (carried 1:1 from PipelineHeader name). description: Top-level flow description (carried 1:1 from PipelineHeader description). + memory: The compiled memory block, or ``None`` when memory does not + participate. Emitted between ``description`` and ``stages`` when + not ``None``; omitted entirely when ``None`` — byte-identical + output for memory-free workflows. stages: Ordered list of flow stages, output as the stages list. """ @@ -44,4 +52,5 @@ class FlowDocument: root_dir: str | None = None name: str description: str + memory: FlowMemory | None = None stages: list[FlowStage] diff --git a/goga/pipeline/compiler/flow_memory.py b/goga/pipeline/compiler/flow_memory.py new file mode 100644 index 00000000..d96dd121 --- /dev/null +++ b/goga/pipeline/compiler/flow_memory.py @@ -0,0 +1,63 @@ +"""The ``FlowMemory`` dataclass — the emitted top-level memory block of a flow-file. + +A flow-file may carry a top-level ``memory`` block between ``description`` and +``stages``. ``FlowMemory`` is the compiled form of the workflow-memory +configuration: it is built by ``compile_flow`` when memory participates and +consumed by ``serialize_flow``. The model is intentionally declarative — it +holds the emitted values, never their resolution. + +The field order IS the emission order of the block keys — ``path``, ``mode``, +``memory_use``, ``max_rules``, ``commit``. ``path`` is the composed memory +root: the fixed root joined with the authored suffix (the bare root when no +suffix was authored) — the caller composes it, this model never does. +``mode`` is the project-memory access mode — the fixed ``"r"`` for the +reflect method (read-only project memory), the materialized authored value +for the alignment method (``"rw"`` by default). ``memory_use`` is the global +participation default — always ``False`` in the emitted block (participation +is per-stage opt-in: afm's ``UseFor(stage)`` inherits this global default +for an unset stage key, so the compiler never leaves it unset at ``True``). +``max_rules`` is the maximum number of +memory rules (always ``>= 1``); ``commit`` is whether memory changes are +committed. A ``None`` field is omitted from the output entirely — the +serializer drops it, it never emits an empty value. + +Only ``mode`` / ``memory_use`` default (to ``None``); ``path`` / ``max_rules`` +/ ``commit`` carry NO defaults — a block is always complete, and +``compile_flow`` is its single construction site (it always passes concrete +``mode`` / ``memory_use`` values; the ``None`` defaults exist for the model's +declarative completeness, not for the compiler's emission). +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(kw_only=True) +class FlowMemory: + """The emitted top-level memory block of a flow-file. + + Field order is fixed (``path``, ``mode``, ``memory_use``, ``max_rules``, + ``commit``) — the emission order of the block keys. reflect method — + ``mode`` the fixed ``"r"``, ``memory_use`` ``False``; alignment method — + ``mode`` the materialized authored value (``"rw"`` by default), + ``memory_use`` ``False``. A ``None`` field is + omitted from the output entirely. + + Args: + path: The composed memory root — the fixed root joined with the + authored suffix. Composed by ``compile_flow``; carried verbatim + here. + mode: The project-memory access mode — ``"r"`` for the reflect method, + the materialized authored value for the alignment method. + memory_use: The global participation default; ``False`` in every + emitted block (participation is per-stage opt-in). + max_rules: The maximum number of memory rules; always ``>= 1``. + commit: Whether memory changes are committed. + """ + + path: str + mode: str | None = None + memory_use: bool | None = None + max_rules: int + commit: bool diff --git a/goga/pipeline/compiler/flow_stage.py b/goga/pipeline/compiler/flow_stage.py index 45ed4139..7cbc3447 100644 --- a/goga/pipeline/compiler/flow_stage.py +++ b/goga/pipeline/compiler/flow_stage.py @@ -10,12 +10,22 @@ an empty list produces ``depends_on: []``. ``fields`` insertion order IS the output order — the serializer iterates it as-is, so the compiler must build it in canonical order: ``interactive``, ``auto_approve``, ``auto_run``, ``command``, -``prompt``, ``description``, ``agents``, ``supervisor``, ``supervisor_prompt``, +``prompt``, ``description``, ``buttons``, ``agents``, ``supervisor``, +``supervisor_prompt``, ``skills``, ``script_before``, ``script``, ``script_after``, ``script_timeout``, -then any unknown +``reflect``, ``memory_use``, then any unknown keys alphabetically. ``auto_run`` (bool) is present only when the stage's effective trigger is ``manual`` — the value is always ``False``; -``auto_run: true`` is never assembled. +``auto_run: true`` is never assembled. ``buttons`` (map of str→str) is present +only when the workflow supplied a non-empty notes instruction for the stage — +the map passes through verbatim. ``reflect`` (map of file + mode) is present +only when the memory block is emitted and the stage's reflect instruction is +effective — the authored file verbatim, the materialized mode; uniform across +every loop-expanded copy. ``memory_use`` (bool) is present only when the memory +block is emitted under the alignment method — ``True`` on a participating +stage, an explicit ``False`` on every non-participating one. Both occupy the +canonical slots immediately after ``script_timeout``. A stage of a memory-free +workflow carries neither key. """ from __future__ import annotations @@ -34,13 +44,26 @@ class FlowStage: depends_on: Predecessor step ids, or ``None`` when absent. fields: Extra step fields in canonical key order (``interactive``, ``auto_approve``, ``auto_run``, ``command``, - ``prompt``, ``description``, ``agents``, ``supervisor``, - ``supervisor_prompt``, ``skills``, ``script_before``, ``script``, - ``script_after``, ``script_timeout``, then unknown keys + ``prompt``, ``description``, ``buttons``, ``agents``, + ``supervisor``, ``supervisor_prompt``, ``skills``, + ``script_before``, ``script``, + ``script_after``, ``script_timeout``, ``reflect``, ``memory_use``, + then unknown keys alphabetically). ``auto_run`` (bool) is present only when the stage's effective trigger is ``manual`` — the value is always ``False``; ``auto_run: true`` is - never assembled. + never assembled. ``buttons`` (map of str→str) is present only when + the workflow supplied a non-empty notes instruction for the stage + — the map passes through verbatim. ``reflect`` (map of file + + mode) is present only when the memory block is emitted and the + stage's reflect instruction is effective — the authored file + verbatim, the materialized mode; uniform across every + loop-expanded copy. ``memory_use`` (bool) is present only when the + memory block is emitted under the alignment method — ``True`` on a + participating stage, an explicit ``False`` on every + non-participating one. Both occupy the canonical slots immediately + after ``script_timeout``; a stage of a memory-free workflow + carries neither key. """ id: str diff --git a/goga/pipeline/compiler/serialize_flow.py b/goga/pipeline/compiler/serialize_flow.py index 3bc6328e..b31ddb5d 100644 --- a/goga/pipeline/compiler/serialize_flow.py +++ b/goga/pipeline/compiler/serialize_flow.py @@ -3,22 +3,32 @@ ``serialize_flow`` is the serialization half of the compiler cell: it takes a fully-built ``FlowDocument`` (with ``FlowStage.fields`` already in canonical key order — enforced by ``compile_flow``) and renders it into the canonical afm -flow-file format. It performs no file I/O and no reordering. +flow-file format. It performs no file I/O and no reordering. The top-level keys +are emitted in fixed order — ``prompt`` (when present), ``root_dir`` (when +supplied), ``name``, ``description``, ``memory`` (when memory participates), +``stages``; the memory block carries the fixed key order ``path``, ``mode``, +``memory_use``, ``max_rules``, ``commit`` with every present value a plain +scalar and a ``None`` field omitted entirely, while the stage memory keys +(``reflect`` mapping, ``memory_use`` bool) ride the regular ``fields`` +emission — a block-style mapping and a plain bool scalar respectively. The non-standard rules are isolated behind marker subclasses and their custom representers on ``_CanonicalDumper``: flow-style for ``agents`` (``_FlowAgents``) while ``skills`` and ``depends_on`` stay block-style, and block-literal scalar style for the top-level ``prompt`` (``_BlockLiteralPrompt``) and for multi-line ``script_before``/``script``/``script_after``/``script_timeout`` -stage fields (``_BlockLiteralScript``). ``serialize_flow`` wraps any ``agents`` +stage fields and multi-line ``buttons`` values (``_BlockLiteralScript``). +``serialize_flow`` wraps any ``agents`` list value in ``_FlowAgents``, any non-``None`` top-level prompt in -``_BlockLiteralPrompt``, and any multi-line ``script_*`` string value in +``_BlockLiteralPrompt``, any multi-line ``script_*`` string value in +``_BlockLiteralScript``, and any multi-line ``buttons`` map value in ``_BlockLiteralScript`` before passing the document to ``yaml.dump``, so the rules never leak into the rest of the pipeline. The default ``beautiful_yaml`` parameters render a multi-line string -single-quoted, so the block-literal marker is mandatory for multi-line scripts; -single-line scripts and the boolean ``auto_approve``/``auto_run`` fields stay -plain scalars. +single-quoted, so the block-literal marker is mandatory for multi-line scripts +and multi-line button texts; +single-line scripts, single-line button values, and the boolean +``auto_approve``/``auto_run`` fields stay plain scalars. """ from __future__ import annotations @@ -86,7 +96,11 @@ def _build_stage_repr(stage: FlowStage) -> dict[str, object]: they serialize in block-literal scalar style (the default parameters render a multi-line string single-quoted); single-line scripts and the boolean ``auto_approve``/``auto_run`` stay plain - scalars. ``auto_run`` occupies the canonical field slot immediately after + scalars. A ``buttons`` mapping is rebuilt entry by entry — single-line values + stay plain scalars (quoted as needed), multi-line values are wrapped in + ``_BlockLiteralScript`` — preserving the map's insertion order (never sorted); + the mapping itself serializes as a regular block-style mapping. + ``auto_run`` occupies the canonical field slot immediately after ``auto_approve`` and is present only when the stage's effective trigger is ``manual`` — the value is always ``False`` (``auto_run: false``; the serializer itself enforces neither rule, it emits ``fields`` verbatim). @@ -110,6 +124,11 @@ def _build_stage_repr(stage: FlowStage) -> dict[str, object]: and "\n" in value ): stage_repr[key] = _BlockLiteralScript(value) + elif key == "buttons" and isinstance(value, dict): + stage_repr[key] = { + note: (_BlockLiteralScript(text) if isinstance(text, str) and "\n" in text else text) + for note, text in value.items() + } else: stage_repr[key] = value @@ -124,21 +143,36 @@ def serialize_flow(doc: FlowDocument) -> str: Top-level keys are emitted in fixed order — ``prompt`` first when not ``None`` (block-literal scalar style), then ``root_dir`` when not - ``None`` (plain scalar), then ``name``, ``description``, ``stages``. + ``None`` (plain scalar), then ``name``, ``description``, ``memory`` (when + present), ``stages``. When ``doc.prompt is None`` the ``prompt`` key is omitted entirely; when ``doc.root_dir is None`` the ``root_dir`` key is omitted entirely. + When ``doc.memory is not None`` the memory block is emitted after + ``description`` and before ``stages`` with the fixed key order ``path``, + ``mode``, ``memory_use``, ``max_rules``, ``commit`` — every present value + a plain scalar, a ``None`` field omitted entirely (no key in the output); + when ``doc.memory is None`` the block is omitted entirely — + byte-identical output for memory-free workflows. Each stage is emitted as ``id``, ``name``, then the stage's ``fields`` verbatim (preserving their canonical order), then ``depends_on`` only when it is not ``None``. ``agents`` lists serialize in flow-style; - ``skills`` and ``depends_on`` serialize in block-style. Multi-line - ``script_before``/``script``/``script_after``/``script_timeout`` string values - serialize in block-literal scalar style; single-line scripts and the boolean - ``auto_approve``/``auto_run`` serialize as plain bool scalars (the canonical - field order slots ``auto_run`` immediately after ``auto_approve``; a stage + ``skills``, ``depends_on``, and the ``buttons`` mapping serialize in + block-style. Multi-line ``script_before``/``script``/``script_after``/ + ``script_timeout`` string values serialize in block-literal scalar style; + single-line scripts and the boolean ``auto_approve``/``auto_run`` serialize + as plain bool scalars (the canonical field order slots ``auto_run`` + immediately after ``auto_approve``; a stage without a manual-effective trigger serializes without the ``auto_run`` key — byte-identical output for trigger-free pipelines — and ``auto_run`` - serializes only as ``auto_run: false``). The output ends with exactly one - trailing newline. + serializes only as ``auto_run: false``). ``buttons`` values serialize as + plain scalars when single-line (quoted as needed) and in block-literal + scalar style when multi-line, preserving the map's insertion order; a stage + without a ``buttons`` key serializes without it. The stage memory keys ride + the same ``fields`` passthrough verbatim: ``reflect`` serializes as a + block-style mapping of plain scalars (``file``, ``mode`` — the nested keys + at the second indent level under ``stages``) and ``memory_use`` as a plain + bool scalar. The output ends with exactly + one trailing newline. The serializer does not reorder, validate, or otherwise transform the input — a document with out-of-order ``fields`` produces out-of-order output. @@ -160,6 +194,20 @@ def serialize_flow(doc: FlowDocument) -> str: top["name"] = doc.name top["description"] = doc.description + + if doc.memory is not None: + top["memory"] = { + key: value + for key, value in ( + ("path", doc.memory.path), + ("mode", doc.memory.mode), + ("memory_use", doc.memory.memory_use), + ("max_rules", doc.memory.max_rules), + ("commit", doc.memory.commit), + ) + if value is not None + } + top["stages"] = [_build_stage_repr(stage) for stage in doc.stages] text = yaml.dump( diff --git a/goga/pipeline/workflow/.usages/memory.md b/goga/pipeline/workflow/.usages/memory.md new file mode 100644 index 00000000..6c0b1dfa --- /dev/null +++ b/goga/pipeline/workflow/.usages/memory.md @@ -0,0 +1,99 @@ +# memory — authoring memory in the workflow-file + +`memory` enables a workflow's participation in the project memory: one +top-level configuration block and two per-stage participation instructions. +The document addresses workflow-file authors: everything described here is +validated structurally at parse time — typos and type or value mismatches +are rejected with a readable error. + +## The top-level `memory:` block + +| Key | Type | Default | Note | +|-----|------|---------|------| +| `method` | `reflect` \| `alignment` | `reflect` | selector of the instruction vocabulary; the selector is goga-side and never reaches the compiled output | +| `path` | str | no suffix | suffix inside the fixed root of the project memory | +| `max_rules` | int >= 1 | `25` | materialized — cannot be silently omitted | +| `commit` | bool | `false` | materialized | +| `mode` | `r` \| `w` \| `rw` | `rw` (materialized) | only with `method: alignment`; with `method: reflect` — a structural error | + +An unknown key is a structural error. A workflow consisting of the `memory:` +block alone is valid (not counted as empty). + +## Instructions of the `stages` block + +| Instruction | Permitted method | Value | +|------------|------------------|-------| +| `reflect: {file, mode?}` | `reflect` | `file` is required — the stage's reflection file (a path shape inside the memory root: no leading `/`, not absolute, no `..`); `mode` is optional (`r`/`w`/`rw`), the `rw` default is materialized | +| `memory: <bool>` | `alignment` | `true` — the stage participates; `false` equals the key's absence | + +A method/instruction mismatch is a structural error: `reflect` is permitted +only under the reflect method, `memory` — only under the alignment method. +The default method is `reflect`, so a `memory` instruction without a +`memory:` block carrying an explicit `method: alignment` is an error. + +Both instructions are forbidden in an extend-entry: the participation of a +new stage is authored in the `stages` block by its name. + +## Minimal examples + +Reflect method (the default) — stages reflecting into a shared memory file: + +```yaml +memory: + max_rules: 40 +stages: + brainstorm: + reflect: + file: shared.md + review: + reflect: + file: shared.md + mode: r +``` + +Alignment method — selective stage participation: + +```yaml +memory: + method: alignment + path: goga-development + mode: rw +stages: + brainstorm: + memory: true + build: + memory: true +``` + +A block without instructions is a valid configuration (a silent no-op at +compilation). + +## Structural errors (complete list) + +| Authoring | Error | +|---|---| +| `memory:` non-mapping | non-mapping memory block in workflow | +| unknown `memory:` key | unknown key in workflow.memory: KEY; valid keys: method, path, max_rules, commit, mode | +| `method` outside {reflect, alignment} | a structural error listing the permitted values | +| `max_rules` not int / < 1 | a structural error | +| `commit`/`memory` not bool | a structural error | +| `mode` outside {r, w, rw} | a structural error listing the permitted values | +| `mode` with `method: reflect` | mode is forbidden in workflow.memory with method: reflect | +| `path`/`reflect.file` of bad shape | a structural error (empty string, leading `/`, absolute path, `..`) | +| `reflect` non-mapping | non-mapping reflect in workflow.stages.NAME | +| unknown `reflect` key | unknown key in workflow.stages.NAME.reflect: KEY; valid keys: file, mode | +| `reflect` without `file` | a structural error | +| `reflect` under alignment | reflect is forbidden in workflow.stages.NAME with method: alignment | +| `memory` under reflect | memory is forbidden in workflow.stages.NAME with method: reflect | +| `reflect`/`memory` in an extend-entry | reflect/memory is forbidden in workflow.extend.NAME | + +## Anti-patterns + +- Do not author `reflect`/`memory` in a stage body or in an extend-entry + body — the single authoring point of the instructions is the `stages` + block of the workflow-file (keys in stage bodies are rejected at + compilation). +- Do not rely on afm defaults: the materialization of the defaults (`mode`, + `max_rules`, `commit`) is mandatory parser behavior, not a style choice. +- Do not set `mode` in the `memory:` block under the default method — that + is a structural error, not a silent ignore. diff --git a/goga/pipeline/workflow/.usages/parse-workflow.md b/goga/pipeline/workflow/.usages/parse-workflow.md index 93240999..d4141f02 100644 --- a/goga/pipeline/workflow/.usages/parse-workflow.md +++ b/goga/pipeline/workflow/.usages/parse-workflow.md @@ -3,12 +3,12 @@ `parse_workflow` reads a project-level workflow-file, validates its structure, and returns a `WorkflowDocument` carrying declarative instructions for the compiler. It is a pure parser: no I/O beyond the path it receives, no agent resolution, no loop -expansion, no stage removal, no approval or manual-launch logic — all of that is -the compiler's job. +expansion, no stage removal, no approval, manual-launch, or note-buttons logic — +all of that is the compiler's job. ## Accepted structure -Top-level keys: `prompt`, `stages`, `extend`. +Top-level keys: `prompt`, `stages`, `extend`, `memory`. ## Per-stage override keys (workflow.stages.<name>) @@ -21,6 +21,36 @@ Top-level keys: `prompt`, `stages`, `extend`. | skip | bool | instruct the compiler to DELETE the stage | | approve | str ("auto"/"plan"/"dialog") | auto-approval directive; one of the three accepted; declarative | | manual | bool | manual-launch instruction; strictly bool; stages block only; declarative | +| notes | map[str]str | note buttons — map "note name → prompt text"; compiled into the stage's `buttons` field; stages block only; declarative | +| reflect | map {file, mode?} | memory-reflection instruction — reflect method only; compiled into the stage's `reflect` field; stages block only; declarative | +| memory | bool | memory-participation instruction — alignment method only; `true` participates, `false` equals absence; compiled into the stage's `memory_use` field; stages block only; declarative | + +## The notes field + +`notes` is an optional per-stage instruction (stages block only) carrying note +buttons — a map of "note name → prompt text". Structurally validated at parse +time: + +- a non-map value → structural error "non-mapping notes in workflow.stages.<name>" +- a non-str value inside the map → structural error "non-str value in + workflow.stages.<name>.notes.<key>" + +| In the workflow-file | WorkflowStage.notes | Meaning | +|----------------------|---------------------|---------| +| key absent | None | no instruction — the stage compiles without `buttons` | +| `notes: {...}` non-empty | the map | the compiler emits the stage's `buttons` field with the same keys and values | +| `notes: {}` empty | None | equals absence — no `buttons` in the output | + +This cell does NOT act on `notes`. The compiler consumes it: a non-None notes +map becomes the per-stage `buttons` field of the flow-file (canonical slot +right after `description`); every loop-expanded copy carries the same +`buttons`; a skipped stage never reaches the application. Interpretation of +the buttons belongs to afm — goga only compiles the field. + +`notes` applies to extend-stages by name: note buttons of a new stage from +`extend` are authored in the same workflow-file as +`stages.<new-stage-name>.notes` — there is no separate authoring inside an +extend-entry. ## Extend-entry keys (workflow.extend.<name>) @@ -35,6 +65,15 @@ approve/depends_on. in workflow.extend.<name>") — the launch mode of a new stage is authored in its body via `trigger`, not via workflow instructions. +`notes` is forbidden in an extend-entry (structural error "notes is forbidden +in workflow.extend.<name>") — note buttons of a new stage are authored in the +stages block by the new stage's name. + +`reflect` and `memory` are forbidden in an extend-entry (structural errors +"reflect is forbidden in workflow.extend.<name>" / "memory is forbidden in +workflow.extend.<name>") — memory participation of a new stage is authored in +the stages block by the new stage's name. + ## The manual field `manual` is an optional per-stage instruction (stages block only) controlling the @@ -62,7 +101,7 @@ there is nothing to cancel. - in the stages block it is an unknown key → structural error "unknown key in workflow.stages.<name>: trigger; valid keys: agent, prompt, loop, skills, - skip, approve, manual" + skip, approve, manual, notes, reflect, memory" - in an extend-entry body it is legal and passes through verbatim — the compiler validates its value (`on_success` | `manual`) at compilation time @@ -75,19 +114,22 @@ structural error raised at parse time: - stages-block: "approve must be one of: auto, plan, dialog in workflow.stages.<name>" - extend-entry: "approve must be one of: auto, plan, dialog in workflow.extend.<name>" -This cell does NOT act on `approve`. The compiler -consumes it and applies two INDEPENDENT effects, each value driving a subset: -for a stage with `approve: "auto"` the stage body's `communication: true` is -suppressed (no `interactive: true`) AND its `roles` containing `planner` emits +This cell does NOT act on `approve`. The compiler consumes it and applies two +INDEPENDENT effects, each value driving a subset: for a stage with +`approve: "auto"` the stage body's `communication: true` is suppressed (no +`interactive: true`) AND its `roles` containing `planner` emits `auto_approve: true`; `approve: "plan"` drives only the `interactive` suppression (communication effect); `approve: "dialog"` drives only the `auto_approve` emission (roles effect). ## Anti-patterns -- Do not perform approval or manual-launch logic here — this cell stays declarative. +- Do not perform approval, manual-launch, or note-buttons logic here — this cell + stays declarative. - Do not pass `approve` into the extend-entry `body` — it is extracted inline. - Do not author `manual` in an extend-entry — the launch mode of a new stage belongs to its body (`trigger`). +- Do not author `notes` in an extend-entry — note buttons of a new stage are + authored in the stages block by its name. - Do not author `trigger` in the stages block — it is a stage-body field, not a workflow modifier. diff --git a/goga/pipeline/workflow/CODEMANIFEST b/goga/pipeline/workflow/CODEMANIFEST index ef1438a6..526770fd 100644 --- a/goga/pipeline/workflow/CODEMANIFEST +++ b/goga/pipeline/workflow/CODEMANIFEST @@ -11,7 +11,8 @@ Annotations: | This cell is a pure parser of project-level workflow-files. It reads a workflow-file, validates its structure (known top-level keys prompt / - stages / extend, field types, loop counts, extend-entry positioning, and + stages / extend / memory, field types, loop counts, extend-entry + positioning, and the inline agent/loop fields of an extend-entry), and returns a `WorkflowDocument` carrying declarative instructions for the compiler. It performs no I/O beyond the path it receives, performs no network or subprocess calls, and has no Imports — it depends only on @@ -50,6 +51,13 @@ Annotations: | stage. This cell performs NO manual-launch logic — the import graph stays one-directional. + notes is a declarative note-buttons instruction (a map of note name → prompt + text, str→str, validated structurally, stages-block only). This cell extracts + it into `WorkflowStage`; the compiler consumes it to emit the per-stage + buttons field of the flow-file. Interpretation of the buttons belongs to + afm — this cell performs NO note-button logic, and the import graph stays + one-directional. + trigger is a full stage-body field, not a workflow key. In the stages block it is an unknown key; in an extend-entry body it passes through verbatim inside the extend-stage body and its value (on_success | manual) is @@ -57,15 +65,26 @@ Annotations: | launch mode of a new stage is authored in its body via trigger, not via workflow instructions. + memory is a declarative workflow-memory configuration — an optional top-level + block plus two per-stage participation instructions. This cell validates the + block and the instructions structurally: key sets, types, value domains, path + shapes, and the correspondence between the block method and the stage + instruction. Materialized defaults live in the document model. This cell + performs NO memory logic — it composes no paths, decides no block emission, + and resolves no stage participation; the consumer consumes the extracted + values. The import graph stays one-directional. + --- -"WorkflowStage(agent: str | None = None, prompt: str | None = None, loop: int | None = None, skills: list[str] | None = None, skip: bool = False, approve: str | None = None, manual: bool | None = None)": +"WorkflowStage(agent: str | None = None, prompt: str | None = None, loop: int | None = None, skills: list[str] | None = None, skip: bool = False, approve: str | None = None, manual: bool | None = None, notes: dict[str, str] | None = None, reflect: WorkflowReflect | None = None, memory: bool | None = None)": location: workflow_stage.py annotations: | Data model of a single per-stage override instruction in a workflow-file — which agent, which prompt, how many loop iterations, which skills to merge, whether to SKIP (delete) the stage, an optional auto-approval directive, - and an optional manual-launch instruction. Constructed by `parse_workflow` + an optional manual-launch instruction, an optional note-buttons + instruction, and optional memory-participation instructions. Constructed + by `parse_workflow` from one entry of the workflow-file stages map; carried verbatim inside `WorkflowDocument`. @@ -100,30 +119,51 @@ Annotations: | a structural error). This cell does NOT act on `manual` — the compiler applies the force / cancel logic. None when not specified. + `notes`: optional map of note name → prompt text consumed by the compiler + to emit the per-stage buttons field of the flow-file. None when + not specified; an empty map equals absence — `parse_workflow` + normalizes it to None, so the model carries either None or a + non-empty map. This cell does NOT act on `notes` — it is + declarative; the compiler emits the buttons. + `reflect`: optional memory-reflection instruction. Declarative — extracted + here, consumed by the compiler to emit the stage's reflect field. + None when not specified. + `memory`: optional memory-participation instruction. Declarative — + extracted here, consumed by the compiler to emit the stage's + memory participation. None when not specified; an explicit false + equals absence (normalized to None by `parse_workflow` — no + separate semantics for false). Build the data model with the standard library dataclasses module (NOT pydantic, per `convention`). Use @dataclass(kw_only=True). Requirements: - Use @dataclass(kw_only=True) (per `convention`) - - Fields agent/prompt/loop/skills/approve/manual default to None; `skip` - defaults to False (NOT None); `manual` defaults to None (NOT False) — an - absent key and an explicit manual: false are DIFFERENT instructions and - must stay distinguishable to the compiler - - Field order is fixed: agent, prompt, loop, skills, skip, approve, manual - — matches the canonical order of the per-stage keys in the workflow-file + - Fields agent/prompt/loop/skills/approve/manual/notes/reflect/memory + default to None; `skip` defaults to False (NOT None); `manual` defaults + to None (NOT False) — an absent key and an explicit manual: false are + DIFFERENT instructions and must stay distinguishable to the compiler + - Field order is fixed: agent, prompt, loop, skills, skip, approve, + manual, notes, reflect, memory — matches the canonical order of the + per-stage keys in the workflow-file + - An explicit memory: false is normalized to None at parse time - `approve` accepts ONLY "auto"/"plan"/"dialog"; `manual` accepts ONLY True/False — any other value is rejected by `parse_workflow` as a structural error before this dataclass is built Constraints: - - Do not validate loop >= 1 / skills list[str] / skip bool / manual bool - here — `parse_workflow` enforces these during parsing + - Do not validate loop >= 1 / skills list[str] / skip bool / manual bool / + notes dict[str, str] here — `parse_workflow` enforces these during + parsing - Do not resolve agent to a wrapper path, merge skills, DELETE the stage, or RECONNECT dependents here — all the compiler's job - Do not act on `approve` or `manual` here — they are declarative; the compiler performs the approval and manual-launch logic when applying the workflow + - Do not act on `notes` here — it is declarative; the compiler emits the + buttons when applying the workflow + - Do not act on `reflect` or `memory` here — they are declarative; the + compiler performs the emission when applying the workflow properties: "agent -> str | None": | Agent name consumed by the compiler to compose the wrapper path, or None. @@ -145,6 +185,52 @@ Annotations: | False = cancel). Declarative — extracted here, consumed by the compiler to force or cancel the stage's manual launch mode. None when not specified. + "notes -> dict[str, str] | None": | + Optional note-buttons instruction (map of note name → prompt text). + Declarative — extracted here, consumed by the compiler to emit the stage's + buttons field. None when not specified (an empty map equals absence). + "reflect -> WorkflowReflect | None": | + Optional memory-reflection instruction (file + access mode). Declarative + — extracted here, consumed by the compiler to emit the stage's reflect + field. None when not specified. + "memory -> bool | None": | + Optional memory-participation instruction. Declarative — extracted + here, consumed by the compiler to emit the stage's memory participation. + None when not specified; an explicit false equals absence. + +"WorkflowReflect(file: str, mode: str = \"rw\")": + location: workflow_reflect.py + annotations: | + Data model of a per-stage memory-reflection instruction — which memory + file the stage reflects into and with which access mode. Constructed by + `parse_workflow`; carried inside `WorkflowStage`. + + `file`: the reflection file — a path shape inside the memory root; + carried verbatim + `mode`: the access mode — one of "r", "w", "rw"; materialized to "rw" + when the authoring entry omits it + + Build the data model with the standard library dataclasses module + (NOT pydantic, per `convention`). Use @dataclass(kw_only=True). + + Requirements: + - Use @dataclass(kw_only=True) (per `convention`) + - `file` is required (no default); `mode` defaults to "rw" — a + materialized value, not an omission + - Field order is fixed: file, mode + + Constraints: + - Do not validate the path shape or the mode domain here — + `parse_workflow` enforces them during parsing + - Do not resolve `file` against any memory root here — the consumer + composes paths + properties: + "file -> str": | + The reflection file — a path shape inside the memory root; carried + verbatim. + "mode -> str": | + The access mode — one of "r", "w", "rw"; materialized to "rw" when the + authoring entry omits it. "WorkflowExtendStage(before: list[str] | None = None, after: list[str] | None = None, agent: str | None = None, loop: int | None = None, approve: str | None = None, body: dict[str, Any])": location: workflow_extend_stage.py @@ -207,12 +293,63 @@ Annotations: | Verbatim stage body excluding before, after, agent, loop, approve, and depends_on. Open-ended. -"WorkflowDocument(prompt: str | None = None, stages: dict[str, WorkflowStage] | None = None, extend: dict[str, WorkflowExtendStage] | None = None)": +"WorkflowMemory(method: str = \"reflect\", path: str | None = None, max_rules: int = 25, commit: bool = False, mode: str | None = None)": + location: workflow_memory.py + annotations: | + Data model of the workflow-memory configuration block — the authoring + form of the flow-level memory settings. Constructed by `parse_workflow` + with materialized defaults; carried inside `WorkflowDocument`. + + `method`: the authoring method — "reflect" or "alignment". A goga-side + selector of the instruction vocabulary; never part of any + output. + `path`: authored suffix inside the fixed memory root; None means no + suffix. + `max_rules`: the maximum number of memory rules; always >= 1. + `commit`: whether memory changes are committed. + `mode`: the project-memory access mode — one of "r", "w", "rw"; exists + only for the "alignment" method (None for "reflect"). + + Build the data model with the standard library dataclasses module + (NOT pydantic, per `convention`). Use @dataclass(kw_only=True). + + Requirements: + - Use @dataclass(kw_only=True) (per `convention`) + - Defaults are materialized values, not omissions: method "reflect", + max_rules 25, commit False; mode "rw" for the "alignment" method, None + for "reflect" + - path carries the authored suffix only — the fixed root prefix is not + part of this model + - Field order is fixed: method, path, max_rules, commit, mode + + Constraints: + - Do not validate keys, types, or value domains here — `parse_workflow` + enforces them during parsing + - Do not compose the memory root prefix here — the consumer composes the + final path + - Do not act on `method` here — it is declarative; the consumer selects + the emission form + properties: + "method -> str": | + The authoring method — "reflect" or "alignment". A goga-side selector + of the instruction vocabulary; never part of any output. + "path -> str | None": | + Authored suffix inside the fixed memory root; None means no suffix. + "max_rules -> int": | + The maximum number of memory rules; always >= 1. + "commit -> bool": | + Whether memory changes are committed. + "mode -> str | None": | + The project-memory access mode — one of "r", "w", "rw"; exists only for + the "alignment" method, None for "reflect". + +"WorkflowDocument(prompt: str | None = None, stages: dict[str, WorkflowStage] | None = None, extend: dict[str, WorkflowExtendStage] | None = None, memory: WorkflowMemory | None = None)": location: workflow_document.py annotations: | Aggregated workflow-file document — the parsed representation of a - workflow-file as a single value, combining an optional top-level prompt - and a map of per-stage override instructions. Built by `parse_workflow` + workflow-file as a single value, combining an optional top-level prompt, + a map of per-stage override instructions, and an optional workflow-memory + configuration. Built by `parse_workflow` and consumed by the compiler via its workflow parameter. `prompt`: top-level prompt text that the compiler emits as the first @@ -231,9 +368,14 @@ Annotations: | top-level prompt and no per-stage overrides. `extend`: map of new-stage extend-instructions keyed by stage name; an entry is embedded into the target pipeline by the compiler and - positioned via before/after. Stages in `extend` that reference - unknown names are silently ignored with a warning by the compiler. + positioned via before/after. A before/after ref in `extend` + naming no pipeline stage and no extend-stage is a STRUCTURAL + ERROR at compile time — "unknown stage name in + workflow.extend.<name>.before/.after: <ref>". An empty map (default) means the workflow provides no new stages. + `memory`: workflow-memory configuration extracted from the optional + top-level memory block, or None when the workflow-file carries + no block. A workflow consisting of the block alone is valid. Build the data model with the standard library dataclasses module (NOT pydantic, per `convention`). Use @dataclass(kw_only=True). @@ -247,9 +389,15 @@ Annotations: | - `extend` defaults to an empty dict via field(default_factory=dict) in the implementation; the signature default None is a DSL representation, the actual default factory is applied at construction - - A workflow-file with none of a top-level `prompt`, any stage entries, or - any extend entries is rejected by `parse_workflow` with a structural - error before this dataclass is built — at least one must be present + - memory defaults to None; field order is fixed: prompt, stages, extend, + memory + - The empty-workflow rule counts the block: a workflow is empty only when + prompt is None AND stages is empty AND extend is empty AND memory is + None + - A workflow-file with none of a top-level `prompt`, any stage entries, + any extend entries, or the memory block is rejected by `parse_workflow` + with a structural error before this dataclass is built — at least one + must be present Constraints: - Do not validate stage-name keys against any pipeline — the compiler @@ -270,6 +418,9 @@ Annotations: | "extend -> dict[str, WorkflowExtendStage]": | Map of new-stage extend-instructions keyed by stage name. Empty map when the workflow-file has no extend section. + "memory -> WorkflowMemory | None": | + Workflow-memory configuration extracted from the top-level memory + block, or None when the workflow-file carries no block. "parse_workflow(workflow_path: Path) -> workflow: WorkflowDocument": location: parse_workflow.py @@ -300,16 +451,44 @@ Annotations: | error "non-mapping stages block in workflow" - extend: if present, must be a dict; otherwise raise a structural error "non-mapping extend block in workflow" + - memory: if present, must be a dict; otherwise raise a structural + error "non-mapping memory block in workflow" 5. For every other top-level key — raise a structural error - "unknown key in workflow: KEY; valid keys: prompt, stages, extend" + "unknown key in workflow: KEY; valid keys: prompt, stages, extend, + memory" + 6.0. For the memory block (when present), validate each key and value: + - an unknown key raises "unknown key in workflow.memory: KEY; valid + keys: method, path, max_rules, commit, mode" + - method must be a str and one of "reflect"/"alignment"; otherwise + raise "non-str value in workflow.memory.method" (non-str) or + "method must be one of: reflect, alignment in workflow.memory" + (str outside the set) + - path must be a str of a valid path shape (non-empty, no leading "/", + not absolute, no ".."); otherwise raise "non-str value in + workflow.memory.path" (non-str) or "invalid path in + workflow.memory.path: VALUE" (bad shape) + - max_rules must be an int >= 1; otherwise raise "non-int value in + workflow.memory.max_rules" (non-int) or "max_rules must be >= 1 + in workflow.memory" (int < 1) + - commit must be a bool; otherwise raise "non-bool value in + workflow.memory.commit" + - mode must be a str and one of "r"/"w"/"rw"; otherwise raise + "non-str value in workflow.memory.mode" (non-str) or "mode must + be one of: r, w, rw in workflow.memory" (str outside the set). + An authored mode together with the "reflect" method raises "mode + is forbidden in workflow.memory with method: reflect" + - build `WorkflowMemory` with materialized defaults (method "reflect", + max_rules 25, commit False; mode "rw" under "alignment", None under + "reflect") 6.1. For each entry of stages (when present), identified by stage name and stage value: 6.1.1. If the stage value is not a dict — raise a structural error "non-mapping stage NAME in workflow.stages" 6.1.2. Validate the key set of the stage value against agent, prompt, - loop, skills, skip, approve, manual: an unknown key raises "unknown - key in workflow.stages.NAME: KEY; valid keys: agent, prompt, loop, - skills, skip, approve, manual" + loop, skills, skip, approve, manual, notes, reflect, memory: an + unknown key raises "unknown key in workflow.stages.NAME: KEY; valid + keys: agent, prompt, loop, skills, skip, approve, manual, notes, + reflect, memory" 6.1.3. agent (when present) must be a str; otherwise raise "non-str value in workflow.stages.NAME.agent" 6.1.4. prompt (when present) must be a str; otherwise raise @@ -327,9 +506,32 @@ Annotations: | workflow.stages.NAME" (str outside the set) 6.1.9. manual (when present) must be a bool; otherwise raise a structural error "non-bool value in workflow.stages.NAME.manual" - 6.1.10. Build a `WorkflowStage` from the validated values (agent, prompt, - loop, skills, skip, approve, manual); an absent manual key yields - None (NOT False) + 6.1.10. notes (when present) must be a dict and every value must be a + str; otherwise raise a structural error "non-mapping notes in + workflow.stages.NAME" (non-dict) or "non-str value in + workflow.stages.NAME.notes.KEY" (non-str value). An empty map equals + absence — build with notes=None + 6.1.11. reflect (when present) must be a dict with a key set within + {file, mode}: a non-dict value raises "non-mapping reflect in + workflow.stages.NAME"; an unknown key raises "unknown key in + workflow.stages.NAME.reflect: KEY; valid keys: file, mode". file + is required, a str of a valid path shape — a missing file raises + "file is required in workflow.stages.NAME.reflect", a non-str + value raises "non-str value in workflow.stages.NAME.reflect.file", + a bad shape raises "invalid path in + workflow.stages.NAME.reflect.file: VALUE". mode (when present) + is a str and one of "r"/"w"/"rw" — otherwise raise "non-str + value in workflow.stages.NAME.reflect.mode" (non-str) or "mode + must be one of: r, w, rw in workflow.stages.NAME.reflect" (str + outside the set). Build `WorkflowReflect` with the authored + file and mode materialized to "rw" when absent + 6.1.12. memory (when present) must be a bool; otherwise raise + "non-bool value in workflow.stages.NAME.memory". An explicit false + equals absence — build with memory=None + 6.1.13. Build a `WorkflowStage` from the validated values (agent, prompt, + loop, skills, skip, approve, manual, notes, reflect, memory); an + absent manual key yields None (NOT False); an empty notes map yields + None 6.2. For each entry of extend (when present), identified by stage name and entry value: 6.2.1. If the entry value is not a dict — raise a structural error @@ -340,48 +542,65 @@ Annotations: | error "skip is forbidden in workflow.extend.NAME" 6.2.4. If the entry value contains a manual key — raise a structural error "manual is forbidden in workflow.extend.NAME" - 6.2.5. before (when present) must be a list[str]; otherwise raise + 6.2.5. If the entry value contains a notes key — raise a structural + error "notes is forbidden in workflow.extend.NAME" + 6.2.6. If the entry value contains a reflect key — raise a structural + error "reflect is forbidden in workflow.extend.NAME" + 6.2.7. If the entry value contains a memory key — raise a structural + error "memory is forbidden in workflow.extend.NAME" + 6.2.8. before (when present) must be a list[str]; otherwise raise "non-list-of-str before in workflow.extend.NAME" - 6.2.6. after (when present) must be a list[str]; otherwise raise + 6.2.9. after (when present) must be a list[str]; otherwise raise "non-list-of-str after in workflow.extend.NAME" - 6.2.7. agent (when present) must be a str; otherwise raise + 6.2.10. agent (when present) must be a str; otherwise raise "non-str value in workflow.extend.NAME.agent" - 6.2.8. loop (when present) must be an int and >= 1; otherwise raise + 6.2.11. loop (when present) must be an int and >= 1; otherwise raise "non-int value in workflow.extend.NAME.loop" (non-int) or "loop must be >= 1 in workflow.extend.NAME" (int < 1) - 6.2.9. approve (when present) must be a str and one of "auto"/"plan"/"dialog"; + 6.2.12. approve (when present) must be a str and one of "auto"/"plan"/"dialog"; otherwise raise "non-str value in workflow.extend.NAME.approve" (non-str) or "approve must be one of: auto, plan, dialog in workflow.extend.NAME" (str outside the set) - 6.2.10. If neither before nor after is present — raise a structural error + 6.2.13. If neither before nor after is present — raise a structural error "extend entry NAME requires at least one of before/after" - 6.2.11. Other keys of the entry value are NOT validated (open-ended: + 6.2.14. Other keys of the entry value are NOT validated (open-ended: title, prompt, skills, roles, communication, trigger — a full stage-body field — and any other stage field) and pass through verbatim - 6.2.12. Build a `WorkflowExtendStage` from the validated before/after, + 6.2.15. Build a `WorkflowExtendStage` from the validated before/after, agent/loop/approve, and the REMAINING entry value (excluding before, after, agent, loop, approve, and depends_on) as body — agent/loop/ approve are extracted into the model, not carried in body, so they never reach the flow-file as stray stage fields + 6.3. Validate the correspondence between the materialized method and the + per-stage instructions (the method is "reflect" when no block is + authored): + - a reflect instruction under "alignment" raises "reflect is forbidden + in workflow.stages.NAME with method: alignment" + - a memory instruction under "reflect" raises "memory is forbidden in + workflow.stages.NAME with method: reflect" 7. If prompt is None AND stages is empty (no entries) AND extend is empty - (no entries) — raise a structural error "empty workflow — provide at - least prompt, one stage, or one extend entry" - 8. Return `WorkflowDocument` from the parsed prompt and stages + (no entries) AND memory is None — raise a structural error "empty + workflow — provide at least prompt, one stage, one extend entry, or + the memory block" + 8. Return `WorkflowDocument` from the parsed prompt, stages, extend, + and memory Apply `convention` for code style, exception message formatting, and docstring style. Requirements: - Top-level unknown keys are a structural error — only prompt, stages, - and extend are accepted + extend, and memory are accepted - extend-entry depends_on is a structural error; before/after (when present) must be list[str]; at least one of before/after is required - - extend-entry names are NOT validated against any pipeline — unknown - before/after names pass through; the compiler decides whether to apply - or ignore (silently with a warning) + - extend-entry names are NOT validated against any pipeline here — + unknown before/after names pass through; the compiler raises a + structural error on a name absent from the pipeline and the + extend-stages - Per-stage unknown keys are a structural error — only agent, prompt, - loop, skills, skip, approve, manual are accepted + loop, skills, skip, approve, manual, notes, reflect, memory are + accepted - skip (when present) must be a bool; a non-bool value is a structural error - skip is forbidden in an extend-entry — a structural error (skip is @@ -394,6 +613,11 @@ Annotations: | "auto"/"plan"/"dialog"; any other value or a non-str is a structural error - manual is accepted ONLY in the stages block; a non-bool manual is a structural error; manual in an extend-entry is a structural error + - notes (stages-block only) must be a dict of str→str; a non-dict value + raises "non-mapping notes in workflow.stages.NAME"; a non-str value + raises "non-str value in workflow.stages.NAME.notes.KEY"; an empty notes + map is treated as absence (notes=None); notes in an extend-entry is a + structural error - trigger in the stages block is an unknown-key structural error; trigger in an extend-entry body passes through verbatim (validated at compile time, not here) @@ -415,12 +639,21 @@ Annotations: | every name it references exists in each target pipeline; a name absent from a target pipeline is a structural error (not a silent warning+skip) — split or prune such workflows - - A workflow-file with neither prompt nor any stage entries is rejected + - A workflow-file with none of a prompt, any stage entries, any extend + entries, or the memory block is rejected — at least one must be present - agent value is NOT validated against a known agent set; absence of the wrapper file is surfaced by afm at invocation time - prompt contents (top-level and per-stage) are NOT validated — passed through verbatim to the consumer + - The memory block and both instructions are validated structurally: key + sets, types, value domains, path shapes, and the method ↔ instruction + correspondence + - Defaults are materialized in the model, not omitted + - An explicit memory: false equals absence — the model carries None + - A workflow consisting of the memory block alone is valid + - reflect and memory are forbidden in an extend entry — participation of a + new stage is authored in the stages block by its name Constraints: - Do not resolve agent to a wrapper path here — the compiler performs @@ -439,12 +672,20 @@ Annotations: | the interactive-suppress / auto_approve logic - Do not act on manual here — it is declarative; the compiler applies the force / cancel logic + - Do not emit or act on the buttons here — notes is declarative; the + compiler emits the per-stage buttons field - Do not let an inline extend approve reach the extend-stage body — it is extracted into the model (like agent/loop) - Do not skip structural validation on missing files — OSError propagates unchanged (consistent with the compiler behavior) - Do not accept YAML files whose root is not a mapping — that is a structural error + - Do not compose the memory root prefix here — the consumer composes the + final path + - Do not decide block emission or stage participation here — the consumer + decides both + - Do not validate reflect/memory keys inside stage bodies here — stage bodies + are the consumer's input, checked at compilation --- diff --git a/goga/pipeline/workflow/__init__.py b/goga/pipeline/workflow/__init__.py index 908a1924..3841c829 100644 --- a/goga/pipeline/workflow/__init__.py +++ b/goga/pipeline/workflow/__init__.py @@ -9,17 +9,23 @@ entry. With the parser landed, the contract names ``parse_workflow``, ``WorkflowDocument``, and ``WorkflowStage`` are re-exported here, alongside the ``WorkflowSyntaxError`` structural-error type (mirroring the compiler cell's -``StructuralError`` re-export). +``StructuralError`` re-export). The memory surface adds the two authoring data +models — ``WorkflowMemory`` (the top-level memory block) and ``WorkflowReflect`` +(the per-stage reflection instruction) — bringing the facade to seven names. """ from .parse_workflow import WorkflowSyntaxError, parse_workflow from .workflow_document import WorkflowDocument from .workflow_extend_stage import WorkflowExtendStage +from .workflow_memory import WorkflowMemory +from .workflow_reflect import WorkflowReflect from .workflow_stage import WorkflowStage __all__: list[str] = [ "WorkflowDocument", "WorkflowExtendStage", + "WorkflowMemory", + "WorkflowReflect", "WorkflowStage", "WorkflowSyntaxError", "parse_workflow", diff --git a/goga/pipeline/workflow/parse_workflow.py b/goga/pipeline/workflow/parse_workflow.py index bc70e2e7..aa9a9d51 100644 --- a/goga/pipeline/workflow/parse_workflow.py +++ b/goga/pipeline/workflow/parse_workflow.py @@ -14,13 +14,15 @@ A workflow-file is structurally malformed when its YAML is invalid, its root is not a mapping, it carries an unknown top-level or per-stage key, a field has the wrong type (including a non-bool ``manual``), an extend-entry forbids -``depends_on`` / ``skip`` / ``manual`` / mistypes ``before`` / ``after`` / omits -both / mistypes an inline ``agent`` / ``loop`` / ``approve``, a ``loop`` is -below one, or it provides neither a top-level prompt, any stage entry, nor any -extend entry. Each of those raises ``WorkflowSyntaxError`` (a -``ValueError`` subclass, mirroring the compiler cell's ``StructuralError``) -with an authored-time message. A missing or unreadable file lets the underlying -``OSError`` propagate unchanged — consistent with the compiler behavior. +``depends_on`` / ``skip`` / ``manual`` / ``notes`` / ``reflect`` / ``memory`` / +mistypes ``before`` / ``after`` / omits both / mistypes an inline ``agent`` / +``loop`` / ``approve``, a ``loop`` is below one, its memory authoring violates +the structural schema, or it provides neither a top-level prompt, any stage +entry, any extend entry, nor the memory block. Each of those raises +``WorkflowSyntaxError`` (a ``ValueError`` subclass, mirroring the compiler +cell's ``StructuralError``) with an authored-time message. A missing or +unreadable file lets the underlying ``OSError`` propagate unchanged — +consistent with the compiler behavior. ``manual`` is accepted ONLY in the ``stages`` block (strictly a bool; an absent key builds ``None``, NOT ``False`` — the three states stay distinguishable for @@ -30,31 +32,79 @@ compiler validates its value), never via a workflow instruction. This cell does not act on ``manual`` — it is declarative; the compiler applies the force / cancel logic. + +``notes`` is accepted ONLY in the ``stages`` block too (a map of note name → +prompt text, str→str; an empty map equals absence and builds ``None``), and is +likewise forbidden in an extend-entry — the compiler consumes it per stage name +to emit the flow-file buttons. This cell does not act on ``notes`` — it is +declarative; the runtime meaning of the buttons belongs to afm. + +``memory`` is a declarative workflow-memory configuration — an optional +top-level block plus two per-stage participation instructions. The block keys +are ``method`` (``reflect``/``alignment``), ``path`` (a suffix inside the fixed +memory root), ``max_rules`` (int >= 1), ``commit`` (bool), and ``mode`` +(``r``/``w``/``rw`` — alignment only, a structural error under ``reflect``); +``path`` and every ``reflect.file`` must be a valid path shape (non-empty, not +absolute, no ``..``). Under ``reflect`` a stage carries ``reflect: {file, +mode?}``; under ``alignment`` it carries ``memory: <bool>`` — an explicit +``false`` equals absence and builds ``None``. The method ↔ instruction +correspondence is enforced here (the default method is ``reflect``, so a +``memory`` instruction without an authored ``method: alignment`` block is an +error), both instructions are forbidden in an extend-entry (participation of a +new stage is authored in the ``stages`` block by its name), and a workflow +consisting of the block alone is valid. Defaults are materialized in the model, +never omitted: ``WorkflowMemory`` (method ``reflect``, ``max_rules`` 25, +``commit`` False, ``mode`` ``rw`` under alignment / ``None`` under reflect) and +``WorkflowReflect`` (``mode`` ``rw`` when the entry omits it). This cell +performs NO memory logic — it composes no paths, decides no block emission, and +resolves no stage participation; the consumer consumes the extracted values. """ from __future__ import annotations -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any import yaml from .workflow_document import WorkflowDocument from .workflow_extend_stage import WorkflowExtendStage +from .workflow_memory import WorkflowMemory +from .workflow_reflect import WorkflowReflect from .workflow_stage import WorkflowStage # Fixed keys of the top-level workflow mapping. Used for unknown-key rejection. -_TOP_LEVEL_KEYS = ("prompt", "stages", "extend") +_TOP_LEVEL_KEYS = ("prompt", "stages", "extend", "memory") # Fixed keys of a per-stage entry, in canonical order. Used both for unknown-key # rejection and for documenting the accepted per-stage field set. -_STAGE_KEYS = ("agent", "prompt", "loop", "skills", "skip", "approve", "manual") +_STAGE_KEYS = ("agent", "prompt", "loop", "skills", "skip", "approve", "manual", "notes", "reflect", "memory") + +# Fixed keys of the top-level ``memory`` block, in canonical order. Used for +# unknown-key rejection and for documenting the accepted block field set. The +# goga-side ``method`` selector never reaches any output — it selects the +# instruction vocabulary the per-stage entries must conform to. +_MEMORY_KEYS = ("method", "path", "max_rules", "commit", "mode") + +# Fixed keys of a per-stage ``reflect`` instruction, in canonical order. Used +# for the unknown-key rejection of the instruction's own key set. +_REFLECT_KEYS = ("file", "mode") + +# Accepted values of the ``memory`` block's ``method`` selector — the goga-side +# choice of the per-stage instruction vocabulary: ``reflect`` pairs with the +# per-stage ``reflect`` instruction, ``alignment`` with the per-stage ``memory`` +# instruction. ``reflect`` is the default when the block is absent entirely. +_MEMORY_METHODS = ("reflect", "alignment") + +# Accepted values of the project-memory access mode — shared by the block's +# ``mode`` key and a ``reflect`` instruction's ``mode`` key. +_MEMORY_MODES = ("r", "w", "rw") # Keys extracted out of an extend-entry's body before construction: the # positioning keys (``before``/``after``) and the inline default overrides # (``agent``/``loop``/``approve``). Every other key passes through verbatim as -# the stage body (``depends_on``, ``skip`` and ``manual`` never reach the body — -# they are rejected outright). +# the stage body (``depends_on``, ``skip``, ``manual``, ``notes``, ``reflect`` +# and ``memory`` never reach the body — they are rejected outright). _EXTEND_BODY_EXCLUDED = ("before", "after", "agent", "loop", "approve") # Accepted values for the ``approve`` directive (per-stage AND inline extend), @@ -74,13 +124,20 @@ class WorkflowSyntaxError(ValueError): A structural error is an authored-time defect in the workflow-file: invalid YAML, a non-mapping root, an unknown top-level or per-stage key, a - wrong-typed field (including a non-bool ``manual``), an extend-entry that - forbids ``depends_on`` / ``skip`` / ``manual`` / mistypes ``before`` / + wrong-typed field (including a non-bool ``manual`` or a malformed + ``notes``), a malformed memory block or memory instruction (unknown key, + wrong type, value outside its domain, bad path shape, a ``mode`` under the + ``reflect`` method, or a method ↔ instruction mismatch), an extend-entry + that + forbids ``depends_on`` / ``skip`` / ``manual`` / ``notes`` / ``reflect`` / + ``memory`` / mistypes + ``before`` / ``after`` / an inline ``agent`` / an inline ``loop`` / an inline ``approve`` / omits both ``before`` and ``after``, a ``loop`` below one, or a workflow that provides neither a top-level prompt, any stage - entry, nor any extend entry. Agent-name resolution, loop expansion, - extend-stage embedding, and ``depends_on`` rewriting are the compiler's + entry, any extend entry, nor the memory block. Agent-name resolution, loop + expansion, extend-stage embedding, and ``depends_on`` rewriting are the + compiler's responsibility — they never surface as structural errors here. """ @@ -89,24 +146,41 @@ def parse_workflow(workflow_path: Path) -> WorkflowDocument: """Structurally parse a workflow-file into a ``WorkflowDocument``. Read the file at ``workflow_path``, parse it as YAML, validate the expected - top-level keys (``prompt``, ``stages``, ``extend``) and the per-stage key + top-level keys (``prompt``, ``stages``, ``extend``, ``memory``) and the + per-stage key set (``agent``, ``prompt``, ``loop``, ``skills``, ``skip``, ``approve``, - ``manual``), + ``manual``, ``notes``, ``reflect``, ``memory``), type-check each present field (``manual`` strictly a bool; an absent key - builds ``None``, NOT ``False``), validate each extend-entry's positioning + builds ``None``, NOT ``False``; ``notes`` a map of note name → prompt text + whose empty form builds ``None``), validate the optional top-level + ``memory`` block (``method`` one of ``reflect``/``alignment``, ``path`` a + valid path shape, ``max_rules`` an ``int >= 1``, ``commit`` a bool, + ``mode`` one of ``r``/``w``/``rw`` and forbidden under ``reflect``), the + per-stage ``reflect`` instruction (``{file, mode?}``, ``file`` required and + a valid path shape) and the per-stage ``memory`` instruction (strictly a + bool; an explicit ``false`` equals absence), enforce the correspondence + between the materialized method and the per-stage instructions (the method + is ``reflect`` when no block is authored), validate each extend-entry's + positioning (``before``/``after`` as ``list[str]``, ``depends_on`` forbidden, ``skip`` - forbidden, ``manual`` forbidden, at least one of ``before``/``after`` + forbidden, ``manual`` forbidden, ``notes`` forbidden, ``reflect`` + forbidden, ``memory`` forbidden, at least one of + ``before``/``after`` required) and any inline ``agent`` (str) / ``loop`` (int >= 1) / ``approve`` (one of ``auto``/ ``plan``/``dialog``), enforce - ``loop >= 1``, build one ``WorkflowStage`` per ``stages`` entry and one - ``WorkflowExtendStage`` per ``extend`` entry, and return the aggregated + ``loop >= 1``, build one ``WorkflowStage`` per ``stages`` entry, one + ``WorkflowExtendStage`` per ``extend`` entry, and one ``WorkflowMemory`` + from the ``memory`` block, and return the aggregated ``WorkflowDocument``. No content validation beyond the structural schema; no agent-name resolution, no loop expansion, no extend-stage embedding, no - ``depends_on`` rewriting, no stage removal. A ``trigger`` key in the + ``depends_on`` rewriting, no stage removal, and NO memory logic — no + memory-root composition, no block-emission decision, no participation + resolution. A ``trigger`` key in the ``stages`` block is an unknown-key structural error; a ``trigger`` key in an extend-entry body passes through verbatim (the compiler validates its - value). This cell does not act on ``manual`` — it is declarative. + value). This cell does not act on ``manual``, ``notes``, ``reflect``, + ``memory``, or the memory block — they are declarative. Args: workflow_path: Absolute path to the workflow-file. @@ -120,16 +194,26 @@ def parse_workflow(workflow_path: Path) -> WorkflowDocument: (propagated unchanged). WorkflowSyntaxError: If the file is invalid YAML, the root is not a mapping, an unknown top-level or per-stage key is present, a field - has the wrong type (including a non-bool ``skip`` or a non-bool - ``manual``), an extend-entry + has the wrong type (including a non-bool ``skip``, a non-bool + ``manual``, or a ``notes`` that is non-mapping or carries a + non-str value), the ``memory`` block or a memory instruction is + malformed (non-mapping, unknown key, non-str ``method``/``path``/ + ``mode``/``reflect.file``, a value outside its domain, a + ``max_rules`` that is not an ``int >= 1``, a non-bool ``commit`` or + ``memory``, an invalid path shape, a missing ``reflect.file``, a + ``mode`` authored under ``method: reflect``, a ``reflect`` + instruction under ``alignment``, or a ``memory`` instruction under + ``reflect``), an extend-entry is malformed (non-mapping value, ``depends_on`` present, ``skip`` - present, ``manual`` present, ``before``/``after`` not a + present, ``manual`` present, ``notes`` present, ``reflect`` + present, ``memory`` present, ``before``/``after`` + not a ``list[str]``, an inline ``agent`` not a str or ``loop`` not an ``int >= 1`` or ``approve`` not one of ``auto``/``plan``/``dialog``, neither ``before`` nor ``after``), ``loop`` is below one, or the workflow provides neither a top-level prompt, any - stage entry, nor any extend entry. + stage entry, any extend entry, nor the memory block. """ text = workflow_path.read_text() @@ -143,45 +227,55 @@ def parse_workflow(workflow_path: Path) -> WorkflowDocument: if not isinstance(loaded, dict): raise WorkflowSyntaxError("workflow must be a mapping") - prompt, stages_raw, extend_raw = _extract_top_level(loaded) + prompt, stages_raw, extend_raw, memory_raw = _extract_top_level(loaded) + memory = _build_memory(memory_raw) stages = _build_stages(stages_raw) extend = _build_extend(extend_raw) + _validate_instruction_correspondence(stages, memory) - if prompt is None and not stages and not extend: - raise WorkflowSyntaxError("empty workflow — provide at least prompt, one stage, or one extend entry") + if prompt is None and not stages and not extend and memory is None: + raise WorkflowSyntaxError( + "empty workflow — provide at least prompt, one stage, one extend entry, or the memory block" + ) - return WorkflowDocument(prompt=prompt, stages=stages, extend=extend) + return WorkflowDocument(prompt=prompt, stages=stages, extend=extend, memory=memory) def _extract_top_level( loaded: dict[str, Any], -) -> tuple[str | None, dict[str, Any] | None, dict[str, Any] | None]: - """Validate the top-level mapping and split out ``prompt``/``stages``/``extend``. +) -> tuple[str | None, dict[str, Any] | None, dict[str, Any] | None, dict[str, Any] | None]: + """Validate the top-level mapping and split out ``prompt``/``stages``/``extend``/``memory``. Iterates the top-level keys once: ``prompt`` must be a str, ``stages`` must - be a mapping, ``extend`` must be a mapping, and any other key is unknown. + be a mapping, ``extend`` must be a mapping, ``memory`` must be a mapping, + and any other key is unknown. Returns the validated ``prompt`` text (or ``None``), the raw ``stages`` - mapping (or ``None``), and the raw ``extend`` mapping (or ``None``); the - per-stage entries are validated by ``_build_stages`` and the per-extend - entries by ``_build_extend``. + mapping (or ``None``), the raw ``extend`` mapping (or ``None``), and the raw + ``memory`` mapping (or ``None``); the per-stage entries are validated by + ``_build_stages``, the per-extend entries by ``_build_extend``, and the + memory block by ``_build_memory``. Args: loaded: The YAML-parsed top-level mapping. Returns: - A 3-tuple ``(prompt, stages_raw, extend_raw)`` where ``prompt`` is the + A 4-tuple ``(prompt, stages_raw, extend_raw, memory_raw)`` where + ``prompt`` is the validated top-level prompt (``None`` when absent), ``stages_raw`` is the - raw stages mapping (``None`` when absent), and ``extend_raw`` is the raw - extend mapping (``None`` when absent). + raw stages mapping (``None`` when absent), ``extend_raw`` is the raw + extend mapping (``None`` when absent), and ``memory_raw`` is the raw + memory block (``None`` when absent). Raises: WorkflowSyntaxError: If a top-level key is unknown, ``prompt`` is not a - str, ``stages`` is not a mapping, or ``extend`` is not a mapping. + str, ``stages`` is not a mapping, ``extend`` is not a mapping, or + ``memory`` is not a mapping. """ prompt: str | None = None stages_raw: dict[str, Any] | None = None extend_raw: dict[str, Any] | None = None + memory_raw: dict[str, Any] | None = None for key, value in loaded.items(): if key == "prompt": @@ -199,10 +293,15 @@ def _extract_top_level( raise WorkflowSyntaxError("non-mapping extend block in workflow") extend_raw = value + elif key == "memory": + if not isinstance(value, dict): + raise WorkflowSyntaxError("non-mapping memory block in workflow") + + memory_raw = value else: raise WorkflowSyntaxError(f"unknown key in workflow: {key}; valid keys: {', '.join(_TOP_LEVEL_KEYS)}") - return prompt, stages_raw, extend_raw + return prompt, stages_raw, extend_raw, memory_raw def _build_stages(stages_raw: dict[str, Any] | None) -> dict[str, WorkflowStage]: @@ -210,8 +309,9 @@ def _build_stages(stages_raw: dict[str, Any] | None) -> dict[str, WorkflowStage] An absent ``stages`` block (``None``) yields an empty map; a present mapping is validated entry by entry via ``_build_stage``. The map key is the stage - name and is NOT validated against any pipeline here — the compiler matches - names and silently ignores unknown ones with a warning. + name and is NOT validated against any pipeline here — the compiler strictly + validates it at compile time and raises a structural error on a name absent + from both the pipeline body and the extend-stages. Args: stages_raw: The raw ``stages`` mapping, or ``None`` when absent. @@ -230,15 +330,21 @@ def _build_stage(name: Any, value: Any) -> WorkflowStage: The entry value must be a mapping. Its key set is validated against ``agent``, ``prompt``, ``loop``, ``skills``, ``skip``, ``approve``, - ``manual`` (unknown + ``manual``, ``notes``, ``reflect``, ``memory`` (unknown key → structural error); each present field is then type-checked, ``loop`` must be an ``int >= 1``, ``skills`` must be a ``list[str]``, ``skip`` must be - a ``bool``, ``approve`` must be one of ``auto``/``plan``/``dialog``, and - ``manual`` must be a ``bool``. Absent fields + a ``bool``, ``approve`` must be one of ``auto``/``plan``/``dialog``, + ``manual`` must be a ``bool``, ``reflect`` must be a ``{file, mode?}`` + mapping, and ``memory`` must be a ``bool``. Absent fields stay ``None`` on the built ``WorkflowStage`` (``skip`` stays ``False`` — its default — since absence is equivalent to ``False``; ``manual`` stays ``None`` — NOT ``False`` — since an absent key and an explicit - ``manual: false`` are DIFFERENT instructions the compiler must tell apart). + ``manual: false`` are DIFFERENT instructions the compiler must tell apart; + ``memory`` stays ``None`` too — an explicit ``memory: false`` is normalized + to ``None`` at parse time because absence and an explicit false are the + SAME state for this instruction). + ``notes`` must be a ``dict`` of ``str``→``str``; an EMPTY map is normalized + to ``None`` — the model carries either ``None`` or a non-empty map. Args: name: The stage-name map key (used in error messages). @@ -252,45 +358,34 @@ def _build_stage(name: Any, value: Any) -> WorkflowStage: per-stage key is present, ``agent``/``prompt`` is not a str, ``loop`` is not an ``int >= 1``, ``skills`` is not a ``list[str]``, ``skip`` is not a ``bool``, ``approve`` is not - one of ``auto``/``plan``/``dialog``, or ``manual`` is not a - ``bool``. + one of ``auto``/``plan``/``dialog``, ``manual`` is not a + ``bool``, ``notes`` is not a ``dict`` of ``str``→``str``, + ``reflect`` is malformed (see ``_build_reflect``), or ``memory`` + is not a ``bool``. """ if not isinstance(value, dict): raise WorkflowSyntaxError(f"non-mapping stage {name} in workflow.stages") - agent: str | None = None - prompt: str | None = None - loop: int | None = None - skills: list[str] | None = None - skip: bool = False - approve: str | None = None - manual: bool | None = None + # ``_validate_stage_field`` dispatches per key and rejects unknown keys, so + # only the valid stage keys land here — the map is then unpacked onto the + # constructor with per-field defaults for the absent ones (``skip`` stays + # ``False``, everything else ``None``). + fields: dict[str, Any] = {} for key, field_value in value.items(): - validated = _validate_stage_field(name, key, field_value) - if key == "agent": - agent = validated - elif key == "prompt": - prompt = validated - elif key == "loop": - loop = validated - elif key == "skills": - skills = validated - elif key == "skip": - skip = validated - elif key == "approve": - approve = validated - elif key == "manual": - manual = validated + fields[key] = _validate_stage_field(name, key, field_value) return WorkflowStage( - agent=agent, - prompt=prompt, - loop=loop, - skills=skills, - skip=skip, - approve=approve, - manual=manual, + agent=fields.get("agent"), + prompt=fields.get("prompt"), + loop=fields.get("loop"), + skills=fields.get("skills"), + skip=fields.get("skip", False), + approve=fields.get("approve"), + manual=fields.get("manual"), + notes=fields.get("notes"), + reflect=fields.get("reflect"), + memory=fields.get("memory"), ) @@ -300,13 +395,17 @@ def _validate_stage_field(name: Any, key: Any, field_value: Any) -> Any: Dispatches by ``key`` over the ``_STAGE_KEYS`` set, enforcing each field's type (``agent``/``prompt`` str, ``loop`` int >= 1, ``skills`` list[str], ``skip`` bool, ``approve`` one of ``auto``/``plan``/``dialog``, ``manual`` - bool). An unknown key raises - the unknown-key structural error with the full valid-set fragment + bool, ``notes`` a str→str map, ``reflect`` a ``{file, mode?}`` mapping + built by ``_build_reflect``, ``memory`` strictly a bool). An unknown key + raises the unknown-key structural error with the full valid-set fragment (``_STAGE_KEYS`` is the single source of that fragment — ``trigger`` is a full stage-body field, NOT a workflow key, so it lands here as an unknown key). Returns the validated value unchanged (only ``loop`` is normalized via - ``_validate_loop``, which already returns an ``int``). + ``_validate_loop``, which already returns an ``int``; ``notes`` is + normalized via ``_validate_notes``, which returns ``None`` for an empty + map; ``reflect`` is normalized via ``_build_reflect``, which materializes + the mode; ``memory`` is normalized to ``None`` when explicitly ``False``). Args: name: The stage-name map key (used in error messages). @@ -316,14 +415,19 @@ def _validate_stage_field(name: Any, key: Any, field_value: Any) -> Any: Returns: The validated field value (``agent``/``prompt`` str, ``loop`` int, ``skills`` list[str], ``skip`` bool, ``approve`` str equal to one of - ``auto``/``plan``/``dialog``, or ``manual`` bool). + ``auto``/``plan``/``dialog``, ``manual`` bool, ``notes`` a non-empty + ``dict[str, str]`` — ``None`` when the map is empty —, ``reflect`` a + ``WorkflowReflect``, or ``memory`` a bool — ``None`` when explicitly + ``False``). Raises: WorkflowSyntaxError: If ``key`` is an unknown per-stage key, or the field value has the wrong type (non-str agent/prompt, non-int/<1 loop, non-list[str] skills, non-bool skip, an ``approve`` that - is not a str equal to ``auto``/``plan``/``dialog``, or a non-bool - ``manual``). + is not a str equal to ``auto``/``plan``/``dialog``, a non-bool + ``manual``, ``notes`` that is non-mapping or carries a non-str + value, a malformed ``reflect`` instruction, or a non-bool + ``memory``). """ if key in ("agent", "prompt"): return _validate_str_field(f"workflow.stages.{name}", key, field_value) @@ -341,8 +445,17 @@ def _validate_stage_field(name: Any, key: Any, field_value: Any) -> Any: raise WorkflowSyntaxError(f"non-bool value in workflow.stages.{name}.{key}") return field_value - elif key == "approve": - return _validate_approve(f"workflow.stages.{name}", field_value) + elif key in ("approve", "notes"): + # Two scoped single-value validators sharing the stage as their + # location; each validator owns its own message shape. + scope = f"workflow.stages.{name}" + validator = _validate_approve if key == "approve" else _validate_notes + return validator(scope, field_value) + elif key in ("reflect", "memory"): + # Two scoped single-value validators sharing the stage as their + # location; each validator owns its own message shape. + validator = _build_reflect if key == "reflect" else _validate_memory_instruction + return validator(name, field_value) else: raise WorkflowSyntaxError(f"unknown key in workflow.stages.{name}: {key}; valid keys: {', '.join(_STAGE_KEYS)}") @@ -377,7 +490,12 @@ def _build_extend_stage(name: Any, value: Any) -> WorkflowExtendStage: ``manual`` is forbidden too (the launch mode of a NEW stage is authored in its body via ``trigger`` — a full stage-body field that passes through verbatim and is validated by the compiler — never via a workflow - instruction); + instruction); ``notes`` is forbidden likewise (a declarative note-buttons + instruction is stages-block only — the compiler consumes it per stage + name); and the two memory-participation instructions ``reflect`` / + ``memory`` are forbidden likewise (participation of a new stage is + authored in the ``stages`` block by its name — the same channel the + compiler reads); ``before`` and ``after`` (when present) must each be a ``list[str]``; an inline ``agent`` (when present) must be a ``str``; an inline ``loop`` (when present) must be an ``int >= 1`` (``bool`` rejected first, symmetric with @@ -387,11 +505,13 @@ def _build_extend_stage(name: Any, value: Any) -> WorkflowExtendStage: at least one of ``before``/``after`` must be present. Every other key passes through verbatim as the stage body. ``before``, ``after``, ``agent``, ``loop`` and ``approve`` are removed from the body before construction - (``depends_on``, ``skip`` and ``manual`` never reach it: they are rejected - outright). + (``depends_on``, ``skip``, ``manual``, ``notes``, ``reflect`` and + ``memory`` never reach it: they are rejected outright). The structural checks run in the CODEMANIFEST order (step 6.2): - non-mapping → ``depends_on`` → ``skip`` → ``manual`` → ``before`` → + non-mapping → ``depends_on`` → ``skip`` → ``manual`` → ``notes`` → + ``reflect`` → ``memory`` → + ``before`` → ``after`` → ``agent`` → ``loop`` → ``approve`` → at-least-one-of-before/after. The at-least-one check runs LAST so an entry carrying BOTH a positioning defect @@ -409,7 +529,8 @@ def _build_extend_stage(name: Any, value: Any) -> WorkflowExtendStage: Raises: WorkflowSyntaxError: If the entry value is not a mapping, it contains a ``depends_on`` key, it contains a ``skip`` key, it contains a - ``manual`` key, ``before`` is not a + ``manual`` key, it contains a ``notes`` key, it contains a + ``reflect`` key, it contains a ``memory`` key, ``before`` is not a ``list[str]``, ``after`` is not a ``list[str]``, an inline ``agent`` is not a ``str``, an inline ``loop`` is not an ``int >= 1``, an inline ``approve`` is not a str equal to one of @@ -460,29 +581,347 @@ def _build_extend_stage(name: Any, value: Any) -> WorkflowExtendStage: def _reject_forbidden_extend_keys(name: Any, value: dict[str, Any]) -> None: - """Reject the keys an extend-entry must never carry (contract 6.2.2 - 6.2.4). + """Reject the keys an extend-entry must never carry (contract 6.2.2 - 6.2.7). - Three keys are forbidden outright, each with its own message, checked in the + Six keys are forbidden outright, each with its own message, checked in the CODEMANIFEST order before any positioning/type validation: ``depends_on`` (positioning is declared via ``before``/``after`` instead), ``skip`` (a new stage has no existing stage to delete — skip is defined only for existing - pipeline stages via the ``stages`` block), and ``manual`` (the launch mode + pipeline stages via the ``stages`` block), ``manual`` (the launch mode of a new stage is authored in its body via ``trigger``, never via a - workflow instruction). All three never reach the extend body. + workflow instruction), ``notes`` (a declarative note-buttons + instruction is stages-block only — it mirrors ``manual``: the compiler + consumes it per stage name, and an extend-stage receives it through the + ``stages`` block), and the two memory-participation instructions + ``reflect`` / ``memory`` (participation of a new stage is authored in the + ``stages`` block by its name — the same channel the compiler reads). All + six never reach the extend body. Args: name: The stage-name map key (used in error messages). value: The raw entry value for this extend stage. Raises: - WorkflowSyntaxError: If the entry carries ``depends_on``, ``skip``, or - ``manual`` (checked in that order). + WorkflowSyntaxError: If the entry carries ``depends_on``, ``skip``, + ``manual``, ``notes``, ``reflect``, or ``memory`` (checked in that + order). """ - for forbidden_key in ("depends_on", "skip", "manual"): + for forbidden_key in ("depends_on", "skip", "manual", "notes", "reflect", "memory"): if forbidden_key in value: raise WorkflowSyntaxError(f"{forbidden_key} is forbidden in workflow.extend.{name}") +def _build_memory(memory_raw: dict[str, Any] | None) -> WorkflowMemory | None: + """Validate the ``memory`` block and build the ``WorkflowMemory`` (contract 6.0). + + An absent block (``None``) yields ``None`` — the document carries no memory + configuration and the compiler's default-constructed ``WorkflowMemory()`` + supplies the authoring defaults downstream. A present mapping is validated + key by key (unknown key → structural error listing ``_MEMORY_KEYS``; + ``method`` / ``path`` / ``max_rules`` / ``commit`` / ``mode`` each with + their own type and domain checks), then the mode-forbidden-under-reflect + rule runs and the model is built with every default materialized: + ``method`` ``"reflect"``, ``max_rules`` ``25``, ``commit`` ``False``, + ``mode`` ``"rw"`` under alignment (the authored mode verbatim when one is + present) and ``None`` under reflect. + + No memory-root composition happens here — ``path`` carries the authored + suffix only; the consumer joins it with the fixed root. + + Args: + memory_raw: The raw ``memory`` mapping, or ``None`` when absent. + + Returns: + The validated ``WorkflowMemory``, or ``None`` when the block is absent. + + Raises: + WorkflowSyntaxError: If the block carries an unknown key, a value has + the wrong type (non-str ``method``/``path``/``mode``, non-int + ``max_rules`` — ``bool`` counts as non-int —, or a non-bool + ``commit``), a value falls outside its domain (``method`` not one + of ``reflect``/``alignment``, ``mode`` not one of ``r``/``w``/ + ``rw``, ``max_rules`` below one), ``path`` has an invalid shape, or + a ``mode`` is authored together with ``method: reflect``. + """ + if memory_raw is None: + return None + + values: dict[str, Any] = {} + + for key, field_value in memory_raw.items(): + if key == "method": + values["method"] = _validate_memory_method("workflow.memory", field_value) + elif key == "path": + values["path"] = _validate_path_shape("workflow.memory.path", field_value) + elif key == "max_rules": + values["max_rules"] = _validate_max_rules(field_value) + elif key == "commit": + if not isinstance(field_value, bool): + raise WorkflowSyntaxError("non-bool value in workflow.memory.commit") + + values["commit"] = field_value + elif key == "mode": + values["mode"] = _validate_memory_mode("workflow.memory.mode", "workflow.memory", field_value) + else: + raise WorkflowSyntaxError(f"unknown key in workflow.memory: {key}; valid keys: {', '.join(_MEMORY_KEYS)}") + + method = values.get("method", "reflect") + + # The mode exists only for the alignment method — an authored mode under + # the default reflect method is a structural error, not a silent ignore. + if "mode" in values and method == "reflect": + raise WorkflowSyntaxError("mode is forbidden in workflow.memory with method: reflect") + + return WorkflowMemory( + method=method, + path=values.get("path"), + max_rules=values.get("max_rules", 25), + commit=values.get("commit", False), + mode=(values.get("mode", "rw") if method == "alignment" else None), + ) + + +def _build_reflect(name: Any, value: Any) -> WorkflowReflect: + """Validate one ``reflect`` instruction and build the ``WorkflowReflect`` (contract 6.1.11). + + The instruction value must be a mapping whose key set is within + ``{file, mode}`` — a non-mapping value raises the non-mapping error, an + unknown key raises the unknown-key error listing ``_REFLECT_KEYS``. + ``file`` is required and must be a str of a valid path shape; ``mode`` + (when present) must be a str in ``_MEMORY_MODES``. The built model carries + the authored ``file`` verbatim and the mode materialized to ``"rw"`` when + the entry omits it. + + The instruction's validity does NOT depend on the workflow's method here — + the method ↔ instruction correspondence is a separate pass + (``_validate_instruction_correspondence``) that runs after every stage is + built, so a reflect instruction under ``alignment`` is reported by that + pass, not this one. + + Args: + name: The stage-name map key (used in error messages). + value: The raw ``reflect`` entry value. + + Returns: + The validated ``WorkflowReflect`` with the mode materialized. + + Raises: + WorkflowSyntaxError: If the value is not a mapping, carries an unknown + key, omits ``file``, carries a ``file`` that is not a str or not a + valid path shape, or carries a ``mode`` that is not a str in + ``r``/``w``/``rw``. + """ + if not isinstance(value, dict): + raise WorkflowSyntaxError(f"non-mapping reflect in workflow.stages.{name}") + + file_value: str | None = None + mode_value: str | None = None + + for key, field_value in value.items(): + if key == "file": + file_value = _validate_path_shape(f"workflow.stages.{name}.reflect.file", field_value) + elif key == "mode": + mode_value = _validate_memory_mode( + f"workflow.stages.{name}.reflect.mode", + f"workflow.stages.{name}.reflect", + field_value, + ) + else: + raise WorkflowSyntaxError( + f"unknown key in workflow.stages.{name}.reflect: {key}; valid keys: {', '.join(_REFLECT_KEYS)}" + ) + + if file_value is None: + raise WorkflowSyntaxError(f"file is required in workflow.stages.{name}.reflect") + + return WorkflowReflect(file=file_value, mode=mode_value or "rw") + + +def _validate_memory_instruction(name: Any, field_value: Any) -> bool | None: + """Validate a per-stage ``memory`` instruction and return it (normalized). + + The participation instruction is strictly a bool; an explicit ``False`` is + normalized to ``None`` — absence and an opting-out instruction are the + SAME state, so the compiler's ``is True`` check never distinguishes them. + Whether the instruction matches the workflow's method is NOT decided here + — that correspondence is a separate pass + (``_validate_instruction_correspondence``) running after every stage is + built. + + Args: + name: The stage-name map key (used in error messages). + field_value: The raw ``memory`` instruction value. + + Returns: + The instruction when ``True``, or ``None`` (absence) when ``False``. + + Raises: + WorkflowSyntaxError: If ``field_value`` is not a ``bool``. + """ + if not isinstance(field_value, bool): + raise WorkflowSyntaxError(f"non-bool value in workflow.stages.{name}.memory") + + return field_value if field_value else None + + +def _validate_instruction_correspondence( + stages: dict[str, WorkflowStage], + memory: WorkflowMemory | None, +) -> None: + """Reject a per-stage instruction the workflow's method does not allow (contract 6.3). + + The method is ``"reflect"`` when no block is authored — the default + vocabulary is the reflect instruction, so a ``memory: true`` instruction + without an explicit ``method: alignment`` block is a structural error. + Under ``alignment`` a stage carries ``memory: true``; under ``reflect`` it + carries a ``reflect`` instruction. Every stage is checked; the first + mismatch raises with the stage name interpolated verbatim. + + Args: + stages: The built per-stage map (name → ``WorkflowStage``). + memory: The built memory configuration, or ``None`` when the + workflow-file carries no block. + + Raises: + WorkflowSyntaxError: If a stage carries a ``reflect`` instruction under + the alignment method, or a ``memory`` instruction under the reflect + method. + """ + method = memory.method if memory is not None else "reflect" + + for name, stage in stages.items(): + if method == "alignment" and stage.reflect is not None: + raise WorkflowSyntaxError(f"reflect is forbidden in workflow.stages.{name} with method: alignment") + + if method == "reflect" and stage.memory is True: + raise WorkflowSyntaxError(f"memory is forbidden in workflow.stages.{name} with method: reflect") + + +def _validate_memory_method(scope: str, field_value: Any) -> str: + """Validate the ``memory`` block's ``method`` value and return it. + + A non-``str`` value is rejected first (``bool`` is not a ``str``), then any + ``str`` other than ``"reflect"`` / ``"alignment"`` (see + ``_MEMORY_METHODS``) is rejected. ``scope`` is the dotted location up to + but excluding ``method`` (``"workflow.memory"``), used verbatim in both + messages. Mirrors the non-str-then-enum shape of ``_validate_approve``. + + Args: + scope: The dotted location (without the trailing ``.method``). + field_value: The raw ``method`` value to validate. + + Returns: + The validated method (one of ``_MEMORY_METHODS``). + + Raises: + WorkflowSyntaxError: If ``field_value`` is not a ``str``, or is a + ``str`` other than ``"reflect"``/``"alignment"``. + """ + if not isinstance(field_value, str): + raise WorkflowSyntaxError(f"non-str value in {scope}.method") + + if field_value not in _MEMORY_METHODS: + raise WorkflowSyntaxError(f"method must be one of: {', '.join(_MEMORY_METHODS)} in {scope}") + + return field_value + + +def _validate_memory_mode(value_location: str, domain_location: str, field_value: Any) -> str: + """Validate a memory ``mode`` value and return it. + + Shared by the ``memory`` block's ``mode`` key and a ``reflect`` + instruction's ``mode`` key. The two messages carry different locations: + the type message names the exact key (``value_location``, e.g. + ``"workflow.memory.mode"`` or ``"workflow.stages.NAME.reflect.mode"``), + while the domain message names the mapping the key lives in + (``domain_location`` — ``"workflow.memory"`` or + ``"workflow.stages.NAME.reflect"``), matching the container the authored + value was expected to complete. + + Args: + value_location: The dotted location used in the non-str message. + domain_location: The dotted location used in the domain message. + field_value: The raw ``mode`` value to validate. + + Returns: + The validated mode (one of ``_MEMORY_MODES``). + + Raises: + WorkflowSyntaxError: If ``field_value`` is not a ``str``, or is a + ``str`` other than ``"r"``/``"w"``/``"rw"``. + """ + if not isinstance(field_value, str): + raise WorkflowSyntaxError(f"non-str value in {value_location}") + + if field_value not in _MEMORY_MODES: + raise WorkflowSyntaxError(f"mode must be one of: {', '.join(_MEMORY_MODES)} in {domain_location}") + + return field_value + + +def _validate_max_rules(field_value: Any) -> int: + """Validate the ``memory`` block's ``max_rules`` value and return the confirmed ``int``. + + ``bool`` is rejected first — it is a subclass of ``int`` in Python, so + ``max_rules: true`` must be reported as a non-int, not silently accepted — + then non-int types, then the ``>= 1`` bound. Mirrors ``_validate_loop``, + the established bool-first int check of this module. + + Args: + field_value: The raw ``max_rules`` value to validate. + + Returns: + The validated rule cap (an ``int >= 1``). + + Raises: + WorkflowSyntaxError: If ``field_value`` is not an ``int`` (``bool`` + counts as not-an-int), or is an ``int`` below one. + """ + if isinstance(field_value, bool) or not isinstance(field_value, int): + raise WorkflowSyntaxError("non-int value in workflow.memory.max_rules") + + if field_value < 1: + raise WorkflowSyntaxError("max_rules must be >= 1 in workflow.memory") + + return field_value + + +def _validate_path_shape(scope: str, field_value: Any) -> str: + """Validate a memory path value's shape and return the confirmed ``str``. + + Shared by the ``memory`` block's ``path`` key and a ``reflect`` + instruction's ``file`` key: both name a location INSIDE the fixed memory + root, so an empty string, an absolute path (a leading ``/``), or any ``..`` + segment is a structural error — the authored value must be a relative, + non-escaping suffix. ``scope`` is the full dotted location of the key (e.g. + ``"workflow.memory.path"``), interpolated verbatim into both messages; the + invalid-shape message additionally repeats the offending value. + + The value is NOT resolved against any root here — the consumer composes + the final path (this cell performs no memory logic). + + Args: + scope: The dotted location of the key being validated. + field_value: The raw path value to validate. + + Returns: + The validated path suffix, carried verbatim. + + Raises: + WorkflowSyntaxError: If ``field_value`` is not a ``str``, is empty, is + absolute, or contains a ``..`` segment. + """ + if not isinstance(field_value, str): + raise WorkflowSyntaxError(f"non-str value in {scope}") + + path = PurePosixPath(field_value) + + if field_value == "" or path.is_absolute() or ".." in path.parts: + raise WorkflowSyntaxError(f"invalid path in {scope}: {field_value}") + + return field_value + + def _is_list_of_str(value: Any) -> bool: """Return whether ``value`` is a ``list`` whose every element is a ``str``. @@ -588,3 +1027,44 @@ def _validate_approve(scope: str, field_value: Any) -> str: raise WorkflowSyntaxError(f"approve must be one of: {', '.join(_APPROVE_DIRECTIVES)} in {scope}") return field_value + + +def _validate_notes(scope: str, field_value: Any) -> dict[str, str] | None: + """Validate a ``notes`` field value and return it (or ``None`` when empty). + + ``notes`` is a declarative note-buttons instruction: a map of note name → + prompt text. A non-``dict`` value is rejected first — an explicit + ``notes: null`` included, since presence of the key forces the type check + (the presence gating mirrors ``skip``/``manual``) — then every non-``str`` + value is rejected with the note key interpolated verbatim into the location. + Map KEYS are deliberately not validated (the open stance mirrors the afm + agent namespace — afm owns the runtime note grammar), so a non-str key + flows through and lands verbatim in the error-message fragment when a + value fails. An EMPTY map equals absence and returns ``None`` — the + compiler's ``is not None`` check must never see an empty instruction. + ``scope`` is the dotted location up to but excluding ``notes`` (e.g. + ``"workflow.stages.deploy"``), used verbatim in both messages. + + Args: + scope: The dotted location (without the trailing ``.notes``). + field_value: The raw ``notes`` value to validate. + + Returns: + The validated notes map, or ``None`` when the map is empty (absence). + + Raises: + WorkflowSyntaxError: If ``field_value`` is not a ``dict``, or any of + its values is not a ``str``. + """ + if not isinstance(field_value, dict): + raise WorkflowSyntaxError(f"non-mapping notes in {scope}") + + for key, text in field_value.items(): + if not isinstance(text, str): + raise WorkflowSyntaxError(f"non-str value in {scope}.notes.{key}") + + if not field_value: + # An empty map equals absence. + return None + + return field_value diff --git a/goga/pipeline/workflow/workflow_document.py b/goga/pipeline/workflow/workflow_document.py index cfa47241..d3c6dead 100644 --- a/goga/pipeline/workflow/workflow_document.py +++ b/goga/pipeline/workflow/workflow_document.py @@ -1,21 +1,32 @@ """The ``WorkflowDocument`` dataclass — the aggregated workflow-file document. A workflow-file combines an optional top-level ``prompt``, a map of -per-stage override instructions (``stages``, keyed by stage name), and a map -of new-stage declarations (``extend``, keyed by stage name). -``WorkflowDocument`` is the single parsed value representing the whole file. -It is built by ``parse_workflow`` and consumed by the compiler via its -workflow parameter. +per-stage override instructions (``stages``, keyed by stage name), a map of +new-stage declarations (``extend``, keyed by stage name), and an optional +workflow-memory configuration (``memory``). ``WorkflowDocument`` is the +single parsed value representing the whole file. It is built by +``parse_workflow`` and consumed by the compiler via its workflow parameter. The model is intentionally declarative — it carries instructions, never their resolution. ``prompt`` is verbatim text (the compiler emits it as the first top-level key of the flow-file); ``stages`` is a verbatim map of ``WorkflowStage`` instructions (the compiler applies each to the matching pipeline stage); ``extend`` is a verbatim map of ``WorkflowExtendStage`` -declarations (the compiler embeds each as a new stage). No validation lives -here: ``parse_workflow`` enforces every structural invariant (key set, field -types, the at-least-one requirement) and raises a structural error before -this dataclass is built. +declarations (the compiler embeds each as a new stage); ``memory`` is the +workflow-memory configuration extracted from the optional top-level +``memory`` block (a :class:`WorkflowMemory`, or ``None`` when the +workflow-file carries no block) — the compiler decides block emission and +stage participation from it. No validation lives here: ``parse_workflow`` +enforces every structural invariant (key set, field types, the +at-least-one requirement, the memory-block key set and value domains) and +raises a structural error before this dataclass is built. + +The empty-workflow rule counts the memory block: a workflow is empty only +when ``prompt`` is ``None`` AND ``stages`` is empty AND ``extend`` is empty +AND ``memory`` is ``None`` — a workflow consisting of the block alone is +valid. Field order is fixed — ``prompt``, ``stages``, ``extend``, +``memory`` — matching the canonical order of the top-level keys in the +workflow-file. """ from __future__ import annotations @@ -23,6 +34,7 @@ from dataclasses import dataclass, field from .workflow_extend_stage import WorkflowExtendStage +from .workflow_memory import WorkflowMemory from .workflow_stage import WorkflowStage @@ -30,15 +42,16 @@ class WorkflowDocument: """Aggregated workflow-file document — top-level prompt plus per-stage overrides. - ``prompt`` defaults to ``None`` and ``stages``/``extend`` default to - empty dicts via ``field(default_factory=dict)`` (the DSL signature lists + ``prompt`` defaults to ``None``, ``stages``/``extend`` default to empty + dicts via ``field(default_factory=dict)`` (the DSL signature lists ``None``; the factory is applied at construction so two documents never - share a stages or extend map). A workflow-file with neither a top-level - ``prompt``, any stage entries, nor any extend entries is rejected by - ``parse_workflow`` with a structural error before this dataclass is - built — at least one must be present. The new ``extend`` field is - backward-compatible: its default means existing consumers (which never - pass ``extend``) keep working. + share a stages or extend map), and ``memory`` defaults to ``None``. A + workflow-file with neither a top-level ``prompt``, any stage entries, any + extend entries, nor a memory block is rejected by ``parse_workflow`` with + a structural error before this dataclass is built — at least one must be + present. Field order is fixed (``prompt``, ``stages``, ``extend``, + ``memory``) to match the canonical order of the top-level keys in the + workflow-file. Args: prompt: Top-level prompt text emitted by the compiler as the first @@ -50,8 +63,14 @@ class WorkflowDocument: :class:`WorkflowExtendStage` carrying ``before``/``after`` positioning and a verbatim body). Empty map when the workflow-file has no extend section. + memory: Workflow-memory configuration extracted from the optional + top-level ``memory`` block (a :class:`WorkflowMemory` with + materialized defaults), or ``None`` when the workflow-file + carries no block. A workflow consisting of the block alone is + valid. """ prompt: str | None = None stages: dict[str, WorkflowStage] = field(default_factory=dict) extend: dict[str, WorkflowExtendStage] = field(default_factory=dict) + memory: WorkflowMemory | None = None diff --git a/goga/pipeline/workflow/workflow_memory.py b/goga/pipeline/workflow/workflow_memory.py new file mode 100644 index 00000000..e553ba81 --- /dev/null +++ b/goga/pipeline/workflow/workflow_memory.py @@ -0,0 +1,69 @@ +"""The ``WorkflowMemory`` dataclass — the workflow-memory configuration block. + +A workflow-file may carry a top-level ``memory`` block — the authoring form of +the flow-level memory settings. ``WorkflowMemory`` is the parsed representation +of that block. It is constructed by ``parse_workflow`` with materialized +defaults and carried verbatim inside ``WorkflowDocument``. + +The model is intentionally declarative — it holds the configuration, never +its resolution. ``method`` is the authoring method — ``"reflect"`` or +``"alignment"``: a goga-side selector of the instruction vocabulary, never +part of any output (the consumer selects the emission form). ``path`` is the +authored suffix inside the fixed memory root (``None`` means no suffix) — the +fixed root prefix is NOT part of this model; the consumer composes the final +path. ``max_rules`` is the maximum number of memory rules (always ``>= 1``). +``commit`` is whether memory changes are committed. ``mode`` is the +project-memory access mode — one of ``"r"``, ``"w"``, ``"rw"``; it exists only +for the ``"alignment"`` method (``None`` for ``"reflect"``). No validation +lives here either: ``parse_workflow`` enforces every invariant (the block key +set, field types, value domains, path shapes, the mode-only-under-alignment +rule) and raises a structural error before this dataclass is built. + +The field defaults ARE the materialized authoring defaults — the consumer +default-constructs ``WorkflowMemory()`` when the workflow carries no block, +sourcing ``max_rules`` / ``commit`` (and the default method) from them. + +Field order is fixed — ``method``, ``path``, ``max_rules``, ``commit``, +``mode`` — matching the canonical order of the block keys in the +workflow-file. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(kw_only=True) +class WorkflowMemory: + """The workflow-memory configuration extracted from a workflow-file's ``memory`` block. + + Every field carries a materialized default — defaults are values, not + omissions: ``method`` ``"reflect"``, ``path`` ``None``, ``max_rules`` + ``25``, ``commit`` ``False``, ``mode`` ``None`` for the ``"reflect"`` + method (``"rw"`` for ``"alignment"`` once ``parse_workflow`` materializes + it). Field order is fixed (``method``, ``path``, ``max_rules``, + ``commit``, ``mode``) to match the canonical order of the block keys in + the workflow-file. + + Args: + method: The authoring method — ``"reflect"`` or ``"alignment"``. A + goga-side selector of the instruction vocabulary; never part of + any output. This cell does not act on ``method`` — it is + declarative; the consumer selects the emission form. + path: Authored suffix inside the fixed memory root; ``None`` means no + suffix. Carries the authored suffix only — the fixed root prefix + is not part of this model; the consumer composes the final path. + max_rules: The maximum number of memory rules; always ``>= 1``. + ``parse_workflow`` enforces the bound during parsing. + commit: Whether memory changes are committed. + mode: The project-memory access mode — one of ``"r"``, ``"w"``, + ``"rw"``; exists only for the ``"alignment"`` method, ``None`` + for ``"reflect"``. ``parse_workflow`` enforces the domain (and + forbids ``mode`` under ``"reflect"``) during parsing. + """ + + method: str = "reflect" + path: str | None = None + max_rules: int = 25 + commit: bool = False + mode: str | None = None diff --git a/goga/pipeline/workflow/workflow_reflect.py b/goga/pipeline/workflow/workflow_reflect.py new file mode 100644 index 00000000..700d4387 --- /dev/null +++ b/goga/pipeline/workflow/workflow_reflect.py @@ -0,0 +1,50 @@ +"""The ``WorkflowReflect`` dataclass — one per-stage memory-reflection instruction. + +A workflow-file's ``stages`` map may carry a ``reflect`` entry per stage — the +memory-reflection instruction. ``WorkflowReflect`` is the parsed representation +of a single such entry: which memory file the stage reflects into (``file``) +and with which access mode (``mode``). It is constructed by ``parse_workflow`` +and carried verbatim inside ``WorkflowStage``. + +The model is intentionally declarative — it holds the instruction, never its +resolution. ``file`` is the reflection file — a path shape inside the memory +root, carried verbatim (the compiler emits it into the stage's ``reflect`` +field; the consumer composes paths). ``mode`` is the access mode — one of +``"r"``, ``"w"``, ``"rw"``, materialized to ``"rw"`` when the authoring entry +omits it. No validation lives here either: ``parse_workflow`` enforces every +invariant (the ``{file, mode}`` key set, ``file`` required and a valid path +shape, the ``mode`` domain) and raises a structural error before this +dataclass is built. This cell does not resolve ``file`` against any memory +root — the consumer composes the final path. + +Field order is fixed — ``file``, ``mode`` — matching the canonical order of +the reflect-instruction keys in the workflow-file. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(kw_only=True) +class WorkflowReflect: + """A single per-stage memory-reflection instruction from a workflow-file. + + ``file`` is required — every reflect instruction names a reflection file. + ``mode`` defaults to ``"rw"`` — a materialized value, not an omission, so + a consumer reading the model never distinguishes an authored ``rw`` from + an omitted mode. Field order is fixed (``file``, ``mode``) to match the + canonical order of the reflect-instruction keys in the workflow-file. + + Args: + file: The reflection file — a path shape inside the memory root; + carried verbatim. Required (no default); ``parse_workflow`` + enforces the path shape before this dataclass is built. + mode: The access mode — one of ``"r"``, ``"w"``, ``"rw"``; + materialized to ``"rw"`` when the authoring entry omits it. This + cell does not validate the mode domain — ``parse_workflow`` + enforces it during parsing. + """ + + file: str + mode: str = "rw" diff --git a/goga/pipeline/workflow/workflow_stage.py b/goga/pipeline/workflow/workflow_stage.py index 0ec5421e..eefa46c1 100644 --- a/goga/pipeline/workflow/workflow_stage.py +++ b/goga/pipeline/workflow/workflow_stage.py @@ -20,32 +20,44 @@ ``manual`` is an optional manual-launch instruction — strictly a bool, with ``None`` (the default, key absent), ``True`` (force), and ``False`` (explicit cancel) as three DIFFERENT states: an absent key and an explicit -``manual: false`` are distinct instructions (the compiler resolves them). -No validation lives here either: ``parse_workflow`` enforces every invariant -(key set, field types, ``loop >= 1``, ``skip`` is a bool, ``approve`` is one -of ``"auto"``/``"plan"``/``"dialog"``, ``manual`` is a bool) and raises a -structural error before this dataclass is built. +``manual: false`` are distinct instructions (the compiler resolves them); +``notes`` is an optional map of note name → prompt text (a declarative +note-buttons instruction — the compiler emits the stage's ``buttons`` field +from it); ``reflect`` is an optional memory-reflection instruction (a +:class:`WorkflowReflect` naming the reflection file and its access mode); +``memory`` is an optional memory-participation instruction (a bool). The two +memory instructions are declarative — extracted here, consumed by the +compiler to emit the stage's ``reflect`` field / ``memory_use`` participation +when the workflow's memory block is emitted. No validation lives here +either: ``parse_workflow`` enforces every invariant (key set, field types, +``loop >= 1``, ``skip`` is a bool, ``approve`` is one of +``"auto"``/``"plan"``/``"dialog"``, ``manual`` is a bool, ``notes`` is a +str→str map, ``reflect`` is a ``{file, mode}`` mapping, ``memory`` is a bool) +and raises a structural error before this dataclass is built. Field order is fixed — ``agent``, ``prompt``, ``loop``, ``skills``, ``skip``, -``approve``, ``manual`` — to match the canonical order of the per-stage keys -in the workflow-file. +``approve``, ``manual``, ``notes``, ``reflect``, ``memory`` — to match the +canonical order of the per-stage keys in the workflow-file. """ from __future__ import annotations from dataclasses import dataclass +from .workflow_reflect import WorkflowReflect + @dataclass(kw_only=True) class WorkflowStage: """A single per-stage override instruction from a workflow-file. - The six fields ``agent``, ``prompt``, ``loop``, ``skills``, ``approve``, - and ``manual`` default to ``None`` — a workflow-file may omit any of them, - and ``parse_workflow`` produces ``None`` for missing fields; ``skip`` - defaults to ``False``. Field order is fixed (``agent``, ``prompt``, - ``loop``, ``skills``, ``skip``, ``approve``, ``manual``) to match the - canonical order of the per-stage keys in the workflow-file. + The nine fields ``agent``, ``prompt``, ``loop``, ``skills``, + ``approve``, ``manual``, ``notes``, ``reflect``, and ``memory`` default to + ``None`` — a workflow-file may omit any of them, and ``parse_workflow`` + produces ``None`` for missing fields; ``skip`` defaults to ``False``. + Field order is fixed (``agent``, ``prompt``, ``loop``, ``skills``, + ``skip``, ``approve``, ``manual``, ``notes``, ``reflect``, ``memory``) to + match the canonical order of the per-stage keys in the workflow-file. Args: agent: Agent name consumed by the compiler to compose the per-stage @@ -92,6 +104,28 @@ class WorkflowStage: distinguishable to the compiler. This cell does not act on ``manual`` — it is declarative; the compiler applies the force / cancel logic. + notes: Optional note-buttons instruction (a map of note name → + prompt text), or ``None`` when not specified. Declarative — + extracted here, consumed by the compiler to emit the stage's + ``buttons`` field. An empty map equals absence (``parse_workflow`` + normalizes it to ``None``), so this field carries either ``None`` + or a non-empty map. This cell does not act on ``notes`` — it is + declarative. + reflect: Optional memory-reflection instruction (a + :class:`WorkflowReflect` carrying the reflection file and the + access mode), or ``None`` when not specified. Declarative — + extracted here, consumed by the compiler to emit the stage's + ``reflect`` field. This cell does not act on ``reflect`` — it is + declarative; the compiler performs the emission when applying the + workflow. + memory: Optional memory-participation instruction, or ``None`` when + not specified. Declarative — extracted here, consumed by the + compiler to emit the stage's memory participation. An explicit + ``memory: false`` equals absence (normalized to ``None`` by + ``parse_workflow``), so this field carries either ``None`` or + ``True``. This cell does not act on ``memory`` — it is + declarative; the compiler performs the emission when applying the + workflow. """ agent: str | None = None @@ -101,3 +135,6 @@ class WorkflowStage: skip: bool = False approve: str | None = None manual: bool | None = None + notes: dict[str, str] | None = None + reflect: WorkflowReflect | None = None + memory: bool | None = None diff --git a/goga/ralphex/.usages/run-ralphex.md b/goga/ralphex/.usages/run-ralphex.md index cf2d41c1..60cae8d6 100644 --- a/goga/ralphex/.usages/run-ralphex.md +++ b/goga/ralphex/.usages/run-ralphex.md @@ -16,7 +16,7 @@ the CLI/config precedence applied, generates the `.ralphex/config`, and only the from goga.ralphex import run_ralphex plan = "docs/plans/my-plan.md" # resolved by the caller (goga/build) -options = { # resolved ralphex options (CLI > ProjectConfig > omit applied) +options = { # universal ralphex options (CLI > ProjectConfig > omit applied) "worktree": True, "max_iterations": 50, "session_timeout": "30m", @@ -28,17 +28,26 @@ dry_run = False exit_code = run_ralphex(plan, options, dry_run) ``` -Two-pass composition when the task executor and the review executor differ: +Two-pass composition when the task executor and the review executor differ. The +shared `options` dict holds the universal options only — the review-scoped keys +(`base_ref`, `review_patience`) join the review pass alone, never the tasks pass: ```python -# Pass 1 — tasks only (task wrapper in .ralphex/config claude_command) +# Pass 1 — tasks only (task wrapper in .ralphex/config claude_command). +# Universal options only: a review diff base here would scope the wrong phase. exit_code = run_ralphex(plan, {**options, "tasks_only": True}, dry_run) # Pass 2 (only on pass-1 success) — review only (review wrapper rewritten -# into claude_command) +# into claude_command), carrying the review-scoped options if exit_code == 0: exit_code = run_ralphex(plan, {**options, "review": True}, dry_run) ``` +```python +# Review pass scoped to a diff base: base_ref maps to --base-ref and is +# omitted when None or empty — run_ralphex never validates the ref +exit_code = run_ralphex(plan, {**options, "review": True, "base_ref": "origin/1.2.x"}, dry_run) +``` + ```python # Review pass with an env layer: keys of env override the inherited # environment for this subprocess only; the tasks pass runs without a layer. @@ -55,6 +64,10 @@ exit_code = run_ralphex(plan, {**options, "review": True}, dry_run, env={"ANTHRO precedence resolution. Bool keys include `tasks_only` (True → bare `--tasks-only`, tasks without any review) and `review` (True → bare `--review`, review-only pass); False or absent omits the flag. + Review-scoped keys — `review_patience` and `base_ref` — map like any other + key but belong on review-carrying passes only (the caller decides the pass + composition). `base_ref` is forwarded verbatim and omitted from the command + when None or an empty string. - `dry_run: bool` — when True, print the assembled ralphex command to sys.stderr and return 0 without launching. - `env: dict[str, str] | None` — optional environment layer for the ralphex @@ -95,3 +108,5 @@ the docker env-file by the host launcher). in the caller. - Do not pass a build config object (`BuildConfig`/`TaskExecutorConfig`) — `run_ralphex` takes resolved primitives only and imports nothing from `goga/config`. +- Do not pass `base_ref` expecting `run_ralphex` to validate or resolve the + ref — the value is forwarded verbatim; ralphex resolves it. diff --git a/goga/ralphex/CODEMANIFEST b/goga/ralphex/CODEMANIFEST index 273c2e15..0f34da1c 100644 --- a/goga/ralphex/CODEMANIFEST +++ b/goga/ralphex/CODEMANIFEST @@ -49,6 +49,7 @@ Annotations: | - wait (str) → --wait (value flag) - max_iterations (int) → --max-iterations (value flag) - review_patience (int) → --review-patience (value flag) + - base_ref (str) → --base-ref (value flag) `dry_run`: when True, print the assembled ralphex command to sys.stderr and return 0 without launching. `env`: optional environment layer ({str: str}) applied on top of the inherited diff --git a/goga/ralphex/run_ralphex.py b/goga/ralphex/run_ralphex.py index a08fdf48..4ec73ab7 100644 --- a/goga/ralphex/run_ralphex.py +++ b/goga/ralphex/run_ralphex.py @@ -21,6 +21,7 @@ ("wait", "--wait"), ("max_iterations", "--max-iterations"), ("review_patience", "--review-patience"), + ("base_ref", "--base-ref"), ) diff --git a/goga/topics/.usages/creating.md b/goga/topics/.usages/creating.md new file mode 100644 index 00000000..66e286c8 --- /dev/null +++ b/goga/topics/.usages/creating.md @@ -0,0 +1,53 @@ +# topics — creating fresh work + +How to create a new branch off an explicit base with its topic using +the `goga.topics` facade. For consumers that start new work. + +`create_topic` takes the branch name as entered and the base revision. +The branch keeps the name verbatim; the topic directory takes the +normalized slug of the year — the two may deliberately differ +(Feature/Foo_Bar branches into the feature-foo-bar topic). + +## Creating + +```python +from goga.topics import create_topic + +result = create_topic("Feature/Foo_Bar", "origin/main", todo="Fix.") # current year +result = create_topic("Feature/Foo_Bar", "origin/main", todo="Fix.", year="2025") +result = create_topic("Feature/Foo_Bar", "origin/main", todo="Fix.", switch=True) +print(result) # one line describing what was created +``` + +- The base is explicit — any revision git resolves; the branch starts + at it and, by default, the repository stays on the caller's branch. +- The preflight runs before any input: an empty slug, an occupied + branch name or slug, or the current branch hosting the same slug is + a clean error with a hint to the board — creating the existing is an + error, not an update. +- The todo: passed by value it is the todo; without a value an + interactive terminal opens the external editor; without a terminal + and without a value the call is a clean error naming the value + option. +- The default path quarantines the topic into the branch: one commit + carrying `todo.md` — the text as entered plus a trailing newline, + UTF-8 — on top of the base, the branch planted at it, the working + copy untouched. The todo is required on this path — git keeps no + empty directories, so a cancelled entry is a clean error naming the + value option and the switch form; the built-in message applies. +- `switch=True` checks out the fresh branch instead: the topic + directory appears in the working copy and the resolved todo is + written as `todo.md` — uncommitted, the last action of the path; the + todo is optional on this path. +- On an interactive terminal without an explicit publish decision, the + publication ask runs when a todo was obtained — the answer chooses + between the local path and the publication path. + +## Occupancy + +- Occupancy oracles: a local branch ref, a remote-tracking ref, and + the topic directory of the year — exposed as + `check_branch_occupancy`; the branch-tree oracle is + `check_slug_occupancy`. +- No artifact files are written inside the topic directory beyond the + todo file — artifacts belong to their producers. diff --git a/goga/topics/.usages/deleting.md b/goga/topics/.usages/deleting.md new file mode 100644 index 00000000..e4ed91ce --- /dev/null +++ b/goga/topics/.usages/deleting.md @@ -0,0 +1,53 @@ +# topics — deleting identified topics + +How to resolve and delete identified topics with the `goga.topics` +facade. For consumers that tear down work: the topics command layer. + +`resolve_delete_targets` turns identifiers into targets — everything is +checked before anything is removed. `delete_topics` executes the +confirmed deletion. + +## Resolving targets + + from goga.topics import resolve_delete_targets + + targets = resolve_delete_targets(["feature-foo", "release-1-3-0"]) + for target in targets: + print(target.topic, target.branch, target.remote, target.has_dir) + +- Identifier tiers: exact branch name, exact topic slug, prefixes — + plus topic directories of the year no branch hosts. An exact branch + name hosting nothing falls through to the slug tier: an unpublished + topic (its todo uncommitted) resolves through its disk directory, + while the bare branch itself never resolves. +- No match or several matches -> a clean error, no interactive + selection; the whole call is cancelled — all-or-nothing. +- A local branch and its origin twin form one target; repeated + identifiers collapse. +- Merged work is out of scope: a topic hosted only by branches that + are not its own topic branch (the post-merge state) is a clean error + naming the hosting branch — remove it from the hosting branch's tree + instead. A topic carried by both an eligible ref and a merged-work + host deletes its eligible refs but keeps its directory — the merged + host's tree survives the deletion. +- The current branch hosting a target -> a clean error asking to + switch away first. + +## Deleting confirmed targets + + from goga.topics import delete_topics + + result = delete_topics(targets) # the caller has confirmed + print(result) # one line — the outcome + +- The confirmation belongs to the caller; the deletion is + unconditional — no merge checks. +- Local branch + origin twin: both removed, the local first; a failed + remote deletion restores the local branch at its former commit and + raises one clean error — targets removed before the failure stay + removed. +- A directory without branches is removed from disk — the topic + directory joins the deletion of every target (branches or none) + unless a merged-work host carries the topic. +- The deletion push is a network operation of the domain; no fetch + ever happens. diff --git a/goga/topics/.usages/ensuring.md b/goga/topics/.usages/ensuring.md new file mode 100644 index 00000000..1c8f080d --- /dev/null +++ b/goga/topics/.usages/ensuring.md @@ -0,0 +1,44 @@ +# topics — ensuring work: switch or create + +How to bring the repository onto requested work in one call — switching +when it exists, creating it when nothing hosts it — with the `goga.topics` +facade. For consumers that resume *or* start work through a single +identifier. + +`ensure_topic` resolves the identifier exactly like `switch_topic` — +exact branch name, then exact topic slug (a local branch beats its remote +twin), then prefixes, first non-empty tier wins — and falls back to the fast +creation — the branch named as entered, off the current HEAD — only when **zero candidates** +resolve. A resolvable identifier therefore never creates +anything. With `todo=True` the todo entry runs after the switch or the creation. + +## Ensuring work + +```python +from goga.topics import ensure_topic + +result = ensure_topic("prune-history-and-new-status") # current year +result = ensure_topic("Feature/Foo_Bar", year="2025") +print(result) # one line — the outcome +``` + +- Nothing hosts the identifier -> the fast creation from the current + HEAD: the branch named as entered, the switch, the topic directory — + and with `todo=True` the editor entry afterwards (an empty entry + file; a cancelled entry leaves no todo.md). No publication ask + exists here; the configuration base is never read. +- A hosted identifier -> the plain switch outcome; with `todo=True` + the entry follows the switch: the todo.md of the **requested** topic — + the resolution's hosted topic, so a topic merged into another branch + is entered as itself (never a fresh directory of the hosting branch's + name); a branch without a topic gets its topic directory created + first, then the empty entry — the fast process is never interrupted + (a branch name with no slug is a clean error). +- Several candidates -> the numbered list with statuses and a number + prompt; without interactive input the call fails with the list — + ambiguity never escapes into creation. +- An occupied name or an empty slug is a clean error with the reason + and a hint to the board. +- `todo=True` without an interactive terminal is a clean error before + any action. +- Mutations are local-only — no network, no fetch, no push. diff --git a/goga/topics/.usages/publishing.md b/goga/topics/.usages/publishing.md new file mode 100644 index 00000000..96e60efd --- /dev/null +++ b/goga/topics/.usages/publishing.md @@ -0,0 +1,58 @@ +# topics — publishing fresh work + +How to create and publish a topic branch without leaving the current branch +with the `goga.topics` facade. For consumers that register new work on the +remote board while the user keeps working: the topics command group, +higher-level orchestration. + +`publish_topic` takes the branch name as entered, a required multi-line +todo, an explicit base, and a commit message template; `commit_message` +omitted — the built-in default `goga: create topic {slug}`. The branch keeps the +name verbatim; the topic directory takes the normalized slug of the year — +the two may deliberately differ. + +## Publishing fresh work + +```python +from goga.topics import publish_topic + +result = publish_topic( + "Feature/Foo_Bar", + "Fix payment retries.\n\nRetries ignore the backoff cap.", + "origin/main", + "goga: create topic {slug}", +) +print(result) # one line: created and published on the remote +``` + +- The caller stays on their branch: the working copy, the index, and HEAD + are untouched — a dirty tree and a detached HEAD do not interfere. +- The branch carries exactly one commit on top of the base: the todo file + `todo.md` — the text as entered plus a trailing newline, UTF-8 — in the + topic directory of the year; the topic shows the `todo` status. +- The todo is required and non-empty — an empty todo is a clean error + before any mutation. +- The message template replaces {slug} with the topic slug; a template + without the placeholder is used as is. +- A failed publication rolls back fully — the branch is deleted and one + clean error names the reason; a re-run after the cause is resolved + succeeds. +- The base resolves as git resolves it — a local branch is valid; no fetch + happens. + +## Occupancy + +- An occupied name, an empty slug, or a slug already hosted by any branch + of the inventory is a clean error with a hint to the board. +- `check_slug_occupancy` exposes the branch-tree oracle — the slug + duplicate check across the inventory; the three local oracles stay in + `check_branch_occupancy`. + +## Preconditions + +- The origin remote must be configured — a clean error otherwise, before + any mutation. +- The repository git identity must be set — an unset identity is a clean + git error. +- The current branch must not host the same slug — the fast path is only + for fresh work. diff --git a/goga/topics/.usages/switching.md b/goga/topics/.usages/switching.md new file mode 100644 index 00000000..5444ae77 --- /dev/null +++ b/goga/topics/.usages/switching.md @@ -0,0 +1,62 @@ +# topics — switching and continuation + +How to move the repository onto existing work with the `goga.topics` +facade. For consumers that resume work. + +`switch_topic` resolves the identifier, chooses among candidates, and +performs the switch; with `todo=True` it then enters the todo of the +switched topic. Resolution tries three tiers in order — exact branch +name, then exact topic slug (a local branch beats its remote twin), then +prefix matches on branch names and slugs — and takes the first non-empty +tier: an exact match excludes prefix candidates. A branch without a topic is +a valid target. + +## Switching + +```python +from goga.topics import switch_topic + +result = switch_topic("history-com") # prefix match, one candidate +print(result) # one line — the outcome +``` + +- Zero candidates -> a clean error with a hint to the board. +- Several candidates -> a numbered list with statuses and a number prompt; + without interactive input the call fails with the list. +- Already on the hosting branch -> idempotent success, no mutation. +- A dirty working tree is a clean error — probed only when a mutation is + needed. +- Mutations are local-only: checkout of a local branch, or creation of a + local branch from a remote-tracking ref (no network). + +## Switching with the todo entry + +```python +from goga.topics import switch_topic + +result = switch_topic("history-com", todo=True) +``` + +- After the switch the external editor opens with the topic's todo.md: + saving overwrites the file as entered plus a trailing newline, + UTF-8, without a commit; an empty or unchanged file leaves it + untouched. +- Already on the hosting branch: the idempotent success — and the + entry still runs. +- A candidate without a topic under `todo=True` is a clean error — + switching creates nothing. +- `todo=True` without an interactive terminal is a clean error before + any switching. + +## Resolving candidates without switching + +```python +from goga.topics import resolve_switch_candidates + +candidates = resolve_switch_candidates("release-1-3-0", year="2026") +for candidate in candidates: + print(candidate.branch, candidate.topic, candidate.statuses) +``` + +- Read-only; build custom selection UIs on top of the same resolution + order. diff --git a/goga/topics/.usages/todo-entry.md b/goga/topics/.usages/todo-entry.md new file mode 100644 index 00000000..2f18f85a --- /dev/null +++ b/goga/topics/.usages/todo-entry.md @@ -0,0 +1,21 @@ +# topics — entering the todo of a topic + +How to collect or edit the todo.md of a topic in one call with the +`goga.topics` facade. For consumers that continue work on a topic: the +switch and ensure orchestrations, the command layer. + +`enter_topic_todo` opens the external editor with the topic's todo.md — +the existing content when the file exists, an empty entry otherwise — +and writes the saved text without a commit. + + from goga.topics import enter_topic_todo + + written = enter_topic_todo("feature-foo") # current year + written = enter_topic_todo("Feature/Foo_Bar", year="2025") + +- Saved text -> todo.md overwritten as entered plus a trailing + newline, UTF-8 — no commit. +- Cancelled entry (empty or unchanged file) -> False, the file stays + untouched. +- The topic directory must exist — creation belongs to the caller. +- A missing interactive terminal is a clean error before anything. diff --git a/goga/topics/.usages/topic-board.md b/goga/topics/.usages/topic-board.md new file mode 100644 index 00000000..4240594c --- /dev/null +++ b/goga/topics/.usages/topic-board.md @@ -0,0 +1,38 @@ +# topics — the topic board + +How to collect the cross-branch topic inventory with the `goga.topics` +facade. For consumers that show all work of a repository: CLI boards, +reviews, overviews. + +The board sees one year at a time. Local mode reads the full branch inventory +and the current branch from the working copy — uncommitted progress is +visible, and a local branch absorbs its remote twin into one row. Remote mode +lists remote-tracking refs instead; the current branch +shows through its remote twin. No checkout happens: every ref is read +through git plumbing, so the working copy and .git stay untouched. + +## Collecting the board + +```python +from goga.topics import collect_topic_board + +records = collect_topic_board() # current year, local +records = collect_topic_board(year="2025", remote=True) # remote-tracking refs +for record in records: + print(record.topic, record.branch, record.statuses, record.current, record.todo) +``` + +- One `BoardRecord` per hosted topic: the slug, the hosting branch display + name, the maximal status names in scale order, the current and remote + markers, and the todo summary — the first line of the topic's `todo.md` + that yields text after leading `#` markers are stripped and the edges + trimmed, or None when the topic has none. Rows hosted by other branches + read their summaries from the ref trees without checkout; the current + branch's row reads the working copy, so an uncommitted todo edit shows at + once. A `todo.md` whose every line is only `#` markers yields the empty + summary. The file is never modified — the stripping is for display. +- A local branch and its remote twin collapse to one row — the local branch + wins. Two different branches hosting one slug stay two rows. +- Sorting: scale order of the first maximal status, then topic alphabet. +- A year without topics yields an empty list — not an error. +- Strictly read-only. diff --git a/goga/topics/CODEMANIFEST b/goga/topics/CODEMANIFEST new file mode 100644 index 00000000..2edc9d82 --- /dev/null +++ b/goga/topics/CODEMANIFEST @@ -0,0 +1,783 @@ +Imports: + - Types: + - normalize_topic_slug + - resolve_current_branch_name + - topic_exists + - ensure_topic_dir + - resolve_history_root + - resolve_topic_status + - resolve_topic_dir + - resolve_topic_file + - current_year + - StatusScale + - assemble_status_scale + - collect_history_tree + - remove_topic_dir + Usages: + - topic-paths + - topic-statuses + From: goga/history + - Types: + - BranchRef + - list_branch_refs + - read_ref_tree_paths + - read_ref_file + - checkout_local_branch + - create_branch_from_remote_tracking + - create_and_switch_branch + - is_working_tree_clean + - resolve_ref_commit + - commit_file_on_base + - create_branch_at_commit + - delete_local_branch + - delete_remote_branch + - push_branch + - origin_configured + Usages: + - refs-and-switching + - publishing + - deleting + From: goga/topics/git + - Types: + - edit_text + Usages: + - editor-entry + From: goga/topics/editor + +Usages: + convention: .goga/usages/conventions.md + click: .goga/usages/cooks/click.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + Use the `click` practice for the interactive moments of the domain: + the numbered candidate selection, the publication ask, and the + non-interactive detection with its clean error. + Use the `editor-entry` practice for the editor session patterns. + Use the `topic-paths` practice for the consumer patterns of the + history facade — the topic slug, the topic directory of a year, the + existence oracle, the topic todo file path, and the current branch. + Use the `topic-statuses` practice for the status scale patterns of + the history facade — scale assembly and maximal-status computation. + Use the `refs-and-switching` practice for the git patterns of the + topics git cell — the branch inventory, ref tree reading, and file + reading without checkout. + Use the `publishing` practice for the quarantined commit building, + branch planting, publication, and rollback patterns of the topics git + cell. + Use the `deleting` practice for the symmetric local-and-origin + removal and the restore-on-failure patterns of the topics git cell. + + This cell owns the topics domain — the work-tracker view of the + history tree: the cross-branch topic inventory of one year with + per-topic statuses and todo summaries; the switch-identifier + resolution and switching orchestration with the optional todo entry; + the creation procedure off an explicit base with its preflight, the + interactive todo entry, and the publication ask — by default one + quarantined todo commit planted as the branch without a switch, + under the switch flag the checked-out branch with its working-copy + topic directory; the fast creation-and-publication procedure — a + committed branch off an explicit base without switching, pushed to + origin, rolled back fully on a failed publication; the combined + ensure orchestration of the fast process — always from the current + HEAD, the topic directory ensured on any hosting branch, the todo + entry after the switch or the creation; the todo entry of an + existing topic; and the identified-topic deletion — the local + branch, the origin twin, and the topic directory removed + symmetrically with restore on failure. + Topic identity, addressing, and statuses belong to the history + facade; git access to the topics git cell; the editor session to the + editor cell. Git infrastructure failures and the fatal + scale-assembly ImportError surface as click.ClickException. + Mutations are local-only except the two pushes — publication and + deletion; no fetch ever happens. Use relative imports. + +--- + +"BoardRecord(topic: str, branch: str, statuses: list[str], current: bool, remote: bool, todo: str | None = None)": + location: board.py + annotations: | + One row of the topic board — a topic hosted by one branch. + + `topic`: the topic slug + `branch`: the display name of the hosting branch + `statuses`: the qualified names of the maximal present statuses, in + scale order + `current`: True when the row hosts the current working branch + `remote`: True when the hosting ref is remote-tracking + `todo`: the todo summary of the topic — the first line of todo.md that + yields a non-empty result after leading # markers are stripped + and the edges trimmed — or None when the topic has no todo.md + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "topic -> str": | + The topic slug — the directory name of the topic. + "branch -> str": | + The display name of the hosting branch. + "statuses -> list[str]": | + The maximal present status names of the topic, in scale order. + "current -> bool": | + True when the row hosts the current working branch. + "remote -> bool": | + True when the hosting ref is remote-tracking. + "todo -> str | None": | + The todo summary of the topic, or None when the topic has no + todo.md. The summary is computed for display — the file itself is + never modified; a todo.md whose every line reduces to emptiness + yields the empty summary. + +"collect_topic_board(year: str | None = None, remote: bool = False) -> records: list[BoardRecord]": + location: board.py + annotations: | + Collect the cross-branch topic inventory of one year — every topic with + its hosting branch, statuses, and todo summary. + + `year`: optional year as four digits; None means the current year + `remote`: True reads remote-tracking refs instead of local branches + `records`: one `BoardRecord` per hosted topic, sorted by scale order of + the first maximal status, then alphabetically by topic + + Apply the `topic-paths` practice for the year and tree-root patterns. + Apply the `topic-statuses` practice for the scale assembly and + maximal-status computation. + Apply the `refs-and-switching` practice for the inventory, + tree-reading, and file-reading patterns. + + Algorithm: + 1. Resolve the year — `year` when given, otherwise the current year + via `current_year` + 2. Assemble the status scale via `assemble_status_scale` once + 3. Local mode enumerates local branches via `list_branch_refs` and reads + the current branch from the working copy via + `resolve_current_branch_name`; remote mode enumerates the + remote-tracking `BranchRef` entries of the same inventory only. + Local mode takes the full inventory of `list_branch_refs` — a topic + hosted only by a remote-tracking ref keeps its row with the remote + marker + 4. Read the topic tree of every ref under the root resolved via + `resolve_history_root` with `read_ref_tree_paths`, without checkout + 5. For every ref, take the topics of the resolved year with their + artifact paths and compute the maximal statuses — the working copy + over the directory composed by `resolve_topic_dir` via + `resolve_topic_status`, every other ref via the `StatusScale` + 6. Read the todo summary of every hosted topic — the working copy from + todo.md of its directory, every other ref from the todo.md of its + ref tree via `read_ref_file`; the summary is the first line that + yields a non-empty result after leading # markers are stripped and + the edges trimmed — the normalization decides the choice, a line of + # markers alone never qualifies; None when the file is absent, the + empty string when no line qualifies; the file is never modified + 7. Collapse a local branch and its remote twin into one row — the local + branch wins; different branches hosting one slug stay separate rows + 8. Mark the row hosting the current branch + 9. Sort by scale order of the first maximal status, then alphabetically + by topic, and return the records + + Requirements: + - The current branch is read from the working copy — uncommitted + progress is visible; remote mode shows it through its remote twin + - Read-only — no checkout, no worktree, no mutation of any kind + - A year without topics yields an empty list — not an error + - A multi-line todo.md yields its first qualifying line; a todo.md + whose every line reduces to emptiness yields the empty summary + - The todo summary never affects the sort order + + Constraints: + - Do not render — output shaping belongs to the consumer + - Do not cross the year boundary — other years are invisible here + +"SwitchCandidate(branch: str, topic: str | None, statuses: list[str], current: bool, remote: bool)": + location: switching.py + annotations: | + One candidate of a switch-identifier resolution — a branch that may + host the requested work. + + `branch`: the display name of the candidate branch + `topic`: the topic slug the branch hosts, or None for a branch without + a topic + `statuses`: the qualified names of the maximal present statuses, in + scale order + `current`: True for the current branch + `remote`: True when the candidate ref is remote-tracking + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "branch -> str": | + The display name of the candidate branch. + "topic -> str | None": | + The topic slug the branch hosts, or None for a branch without a + topic. + "statuses -> list[str]": | + The maximal present status names of the candidate's topic, in scale + order — empty for a branch without a topic. + "current -> bool": | + True for the current branch. + "remote -> bool": | + True when the candidate ref is remote-tracking. + +"resolve_switch_candidates(identifier: str, year: str | None = None) -> candidates: list[SwitchCandidate]": + location: switching.py + annotations: | + Resolve a switch identifier into its candidate branches. + + `identifier`: the user input — a branch name, a topic slug, or their + prefix + `year`: optional year as four digits; None means the current year + `candidates`: the matching candidates, exact matches first, then prefix + matches + + Apply the `topic-paths` practice for the slug normalization and current + branch patterns. + Apply the `refs-and-switching` practice for the inventory and + tree-reading patterns. + + Algorithm: + 1. Normalize `identifier` into a slug via `normalize_topic_slug` + 2. Collect the branch inventory and the topics of the resolved year + 3. Exact branch name match -> the candidates hosting that name + 4. Exact slug match otherwise -> the branches hosting the slug, local + branches first + 5. Prefix matches otherwise -> the branches whose name starts with + the raw `identifier`, and — when the slug is non-empty — the + branches hosting a topic whose slug starts with it + 6. Collapse the tier to one entry per branch, then return the + candidates with their statuses + + Requirements: + - Exact matches always precede prefix matches + - A branch appears in the result once — a local branch beats its + remote twin, and a branch hosting several topics of the year + contributes its first entry only, so an unambiguous identifier never + reaches a prompt + - A branch without a topic is a valid candidate + - Read-only — no mutation before a choice + + Constraints: + - Do not choose among multiple candidates — selection belongs to the + caller + +"switch_topic(identifier: str, todo: bool = False, year: str | None = None) -> result: str": + location: switching.py + annotations: | + Bring the repository onto the branch hosting the requested work; + with the todo flag, enter the todo of the switched topic after the + switch. + + `identifier`: the user input — a branch name, a topic slug, or + their prefix + `todo`: True enters the todo of the switched topic + `year`: optional year as four digits; None means the current year + `result`: one line describing the outcome + + Apply the `click` practice for the numbered selection prompt and + the non-interactive detection. + Apply the `refs-and-switching` practice for the checkout and + remote-tracking branch patterns. + + Algorithm: + 1. `todo` without an interactive terminal -> clean error before any + switching + 2. Resolve the candidates via `resolve_switch_candidates`; none -> + clean error with a hint to the board; several -> the numbered + list with statuses and the number prompt, or the failure with + the list without interactive input + 3. `todo` and the chosen candidate hosts no topic -> clean error — + switching creates nothing + 4. Already on the hosting branch -> idempotent success without + mutation; with `todo` the entry still runs + 5. A mutation is needed -> probe the working tree cleanliness + first via `is_working_tree_clean`; a dirty tree is a clean + error; local host -> check out the branch via + `checkout_local_branch`; remote-only host -> create the local + branch from the remote-tracking ref via + `create_branch_from_remote_tracking` + 6. With `todo` -> enter the todo of the topic via + `enter_topic_todo` + 7. Return the single result line + + Requirements: + - Every mutation is local — no network, no fetch, no push + - Nothing is mutated before the candidate choice is complete + - The result is exactly one line + + Constraints: + - Do not create a topic for a branch without one + - Do not commit the todo write + - Do not manage the stages of the hosting pipeline — continuation + belongs to the pipeline itself + - Do not return to the previous branch — the switch is the outcome + +"ensure_topic(identifier: str, todo: bool = False, year: str | None = None) -> result: str": + location: ensuring.py + annotations: | + Bring the repository onto the requested work, creating it when + nothing hosts the identifier; with the todo flag, enter the todo of + the work after the switch or the creation. + + `identifier`: the user input — a branch name, a topic slug, or + their prefix + `todo`: True enters the todo of the work + `year`: optional year as four digits; None means the current year + `result`: one line describing the outcome + + Apply the `click` practice for the interactive moments inherited + from the switch orchestration. + Apply the `topic-paths` practice for the slug and topic-directory + patterns of the creation. + Apply the `refs-and-switching` practice for the checkout and + create-and-switch patterns. + + Algorithm: + 1. `todo` without an interactive terminal -> clean error before any + action + 2. Resolve the candidates via `resolve_switch_candidates` + 3. No candidate -> the fast creation: normalize `identifier` into a + slug via `normalize_topic_slug`; an empty slug or an occupancy + conflict — the oracles `check_branch_occupancy` and + `check_slug_occupancy` — is a clean error; create the branch + named as entered from the current HEAD and switch to it via + `create_and_switch_branch`; create the topic directory of the + year via `ensure_topic_dir`; with `todo` enter the todo of the + fresh topic via `enter_topic_todo` — the entry starts only after + the switch + 4. Otherwise -> the switch procedure via `switch_topic` without the + entry; with `todo`, take the hosted topic of the switched work — + the resolution candidate of step 2 whose branch is the current + branch read via `resolve_current_branch_name` (a remote-tracking + candidate matches by its short name) — and: a hosted topic + exists -> enter its todo via `enter_topic_todo`; the hosting + branch hosts no topic -> an empty slug of its name is a clean + error, otherwise create the topic directory of the year via + `ensure_topic_dir`, then enter the todo of the fresh topic via + `enter_topic_todo` + 5. Return the single result line + + Requirements: + - Creation happens only at zero candidates — a resolvable + identifier never creates anything + - The creation always starts from the current HEAD — the + configuration base is never read here + - With `todo`, no step follows the todo write + - Every mutation is local — no network, no fetch, no push + + Constraints: + - Do not ask about publication — the fast process publishes + nothing + - Do not manage the stages of the hosting pipeline — continuation + belongs to the pipeline itself + +"create_topic(branch_name: str, base_ref: str, todo: str | None = None, publish: bool = False, commit_message: str | None = None, year: str | None = None, switch: bool = False) -> result: str": + location: creation.py + annotations: | + Create fresh work — a branch off an explicit base with the name as + entered, its topic of the year, and an optional multi-line todo. + + `branch_name`: the branch name as entered by the user + `base_ref`: the base revision the branch starts from — any revision + string, resolved as git resolves it + `todo`: the todo text; None or an empty string means no value + `publish`: True takes the publication path without the ask + `commit_message`: the message template; None applies the built-in + default + `year`: optional year as four digits; None means the current year + `switch`: True checks out the fresh branch after the creation — + the topic directory and the todo land in the working + copy, uncommitted, and the todo is optional; the default + path quarantines the topic into the branch instead and + needs the todo + `result`: one line describing the outcome + + Apply the `click` practice for the publication ask and the + non-interactive detection. + Apply the `editor-entry` practice for the editor session. + Apply the `topic-paths` practice for the slug, existence, directory + creation, and todo-file path patterns. + Apply the `refs-and-switching` practice for the checkout pattern. + + Algorithm: + 1. Preflight, read-only and before any input: normalize + `branch_name` into a slug via `normalize_topic_slug`; an empty + slug is an input error; the current branch — read via + `resolve_current_branch_name` — hosting the same slug is a + conflict; the occupancy oracles `check_branch_occupancy` and + `check_slug_occupancy` report a conflict; resolve `base_ref` + into its commit via `resolve_ref_commit` — every conflict is a + clean error with a hint to the board + 2. Resolve the todo: a value given is the todo; without a value, an + interactive terminal opens the editor session via `edit_text`, + its cancellation leaves no todo, a non-interactive terminal is a + clean error naming the value option — before any mutation + 3. `publish` without a resolved todo -> clean error asking for the + todo, before any mutation + 4. Neither `publish` nor `switch` without a resolved todo -> clean + error — git keeps no empty directories, so the no-switch work + exists only through its committed todo file + 5. The publication ask — interactive terminal, `publish` not set, + and a todo resolved: the answer chooses the path; no ask + otherwise + 6. The normal path without `switch`: build one quarantined commit + carrying the todo file todo.md — the path resolved via + `resolve_topic_file` — on the base commit, and plant the branch + named as entered at it; the working copy, the index, and HEAD + stay untouched — the caller stays on their branch + 7. The normal path under `switch`: create the branch at the base + commit via `create_branch_at_commit` and switch to it via + `checkout_local_branch` — a failed checkout rolls the planted + branch back via `delete_local_branch` (the occupancy oracle + would otherwise block the retry) —, create the topic directory + of the year via `ensure_topic_dir`, and write the todo file + todo.md when a todo resolved; the write is the last action of + the path + 8. The publication path: delegate to `publish_topic` with the + name, the todo, the base, the template, and the year + 9. Return the single result line + + Requirements: + - Every decision — preflight, todo, ask — precedes the first + mutation + - A failed checkout of the switch path rolls the planted branch + back — nothing of the path stays behind + - The todo.md content carries the todo as entered with exactly one + trailing newline — a todo already ending in one keeps it, a + bare todo gains it — encoded UTF-8; empty lines inside the + text stay as entered + - The no-switch path needs a resolved todo and builds its commit + with the built-in domain message — `commit_message` stays + publication-only + - The topic directory of the switch path exists before the + todo.md file is written; the todo.md file is written only when + a todo resolved + - The branch keeps the name as entered; the topic directory takes + the slug + - The caller stays on their branch unless `switch` is set + + Constraints: + - Do not validate branch-name characters — git owns name validity + - Do not auto-pick suffixed names on a conflict + - Do not write artifact files other than the topic todo file + inside the topic directory + +"enter_topic_todo(topic: str, year: str | None = None) -> written: bool": + location: creation.py + annotations: | + Enter the todo of a topic — the editor session with the topic's + todo.md and the write of the saved text, without a commit. + + `topic`: topic input — a branch name or an already-normalized slug + `year`: optional year as four digits; None means the current year + `written`: True when the saved text was written; False when the + entry was cancelled + + Apply the `editor-entry` practice for the editor session pattern. + Apply the `topic-paths` practice for the todo-file path pattern. + + Algorithm: + 1. Resolve the todo.md path of the topic via `resolve_topic_file`; + an existing file provides the initial text + 2. Open the editor session via `edit_text` with the initial text + 3. A cancelled entry -> False — the file stays untouched + 4. The saved text -> write todo.md as entered with exactly one + trailing newline — a text already ending in one keeps it — + encoded UTF-8, without a commit -> True + + Requirements: + - The write is the last action — nothing follows it + - The topic directory exists — directory creation belongs to the + caller + + Constraints: + - Do not create the topic directory + - Do not commit the write + +"check_branch_occupancy(branch_name: str, slug: str, year: str | None = None) -> conflict: str | None": + location: creation.py + annotations: | + Decide whether the entered branch name and the topic slug are free. + + `branch_name`: branch name as entered — checked against the branch + inventory + `slug`: normalized topic slug — checked against the topic directory of + the year + `year`: optional year as four digits; None means the current year + `conflict`: human-readable reason of the first occupied oracle, or None + when everything is free + + Apply the `topic-paths` practice for the topic-existence oracle + contract. + Apply the `refs-and-switching` practice for the inventory pattern. + + Algorithm: + 1. A local `BranchRef` named `branch_name` exists in the inventory of + `list_branch_refs` -> return the reason + 2. A remote-tracking `BranchRef` whose short name (the part after the + first "/") equals `branch_name` exists -> return the reason + 3. The topic directory of `slug` in the year exists via `topic_exists` + -> return the reason + 4. All three oracles are free -> None + + Requirements: + - The first occupied oracle wins; remaining oracles are not probed + - Read-only — no ref or directory is created + + Constraints: + - Do not resolve remote state over the network — the local inventory + only + +"publish_topic(branch_name: str, todo: str, base_ref: str, commit_message: str | None = None, year: str | None = None) -> result: str": + location: publishing.py + annotations: | + Create fresh work and publish it — a branch off an explicit base + carrying one commit with the topic todo, pushed to origin, while + the caller stays on their branch. + + `branch_name`: the branch name as entered by the user + `todo`: the multi-line todo of the fresh work — written to todo.md + as entered with exactly one trailing newline; required and + non-empty, an empty todo is a clean error asking for it + `base_ref`: the base revision the branch starts from — any revision + string, resolved as git resolves it + `commit_message`: the commit message template — the {slug} + placeholder is replaced with the topic slug; None + applies the built-in default + `year`: optional year as four digits; None means the current year + `result`: one line describing the outcome + + Apply the `topic-paths` practice for the slug, current-branch, and + todo-file path patterns. + Apply the `refs-and-switching` practice for the occupancy inventory + and tree-reading patterns. + Apply the `publishing` practice for the quarantined commit + building, branch planting, publication, and rollback patterns. + + Algorithm: + 1. Normalize `branch_name` into a slug via `normalize_topic_slug` + 2. An empty slug, an empty `todo`, or the current branch — read via + `resolve_current_branch_name` — hosting the same slug -> clean + error, before any mutation + 3. The occupancy oracles `check_branch_occupancy` and + `check_slug_occupancy` report a conflict -> clean error with a + hint to the board + 4. `origin_configured` reads False -> clean error with the reason + 5. Resolve `base_ref` into its commit via `resolve_ref_commit` — an + unresolvable base is a clean error with the reason, before any + mutation + 6. Build the publication commit via `commit_file_on_base` — the + parent commit, the todo.md path resolved via `resolve_topic_file` + as a repository-root-relative posix string, the todo content, + and the applied `commit_message` + 7. Plant the branch named exactly as entered via + `create_branch_at_commit` + 8. Publish via `push_branch`; a failed publication deletes the + branch via `delete_local_branch` and surfaces one clean error + carrying the reason + 9. Return the single result line + + Requirements: + - The working copy, the index, and HEAD stay untouched — the caller + stays on their branch whatever its state; a dirty tree and a + detached HEAD do not interfere + - Every decision is made before the first mutation; the mutation + sequence is the commit build, the branch plant, and the push + - The push to origin is the only network operation of the path; no + fetch ever happens + - A failed publication rolls back fully — the planted branch is + deleted and nothing else was ever mutated; a re-run after the + cause is resolved succeeds + - The todo.md file carries `todo` as entered with exactly one + trailing newline — a todo already ending in one keeps it, a + bare todo gains it — encoded UTF-8; the sole artifact of the + topic directory + - The result is exactly one line + + Constraints: + - Do not validate branch-name characters — git owns name validity + - Do not auto-pick suffixed names on a conflict + - Do not write artifact files other than the topic todo file inside + the topic directory + - Do not switch the caller's branch — the caller keeps their + working state + +"check_slug_occupancy(slug: str, year: str | None = None) -> conflict: str | None": + location: creation.py + annotations: | + Decide whether the topic slug of the year is already hosted by any + branch of the inventory — read from the branch trees, without checkout. + + `slug`: the normalized topic slug — checked against the topic directory + of the year across every branch tree + `year`: optional year as four digits; None means the current year + `conflict`: human-readable reason naming the hosting branch, or None + when no branch hosts the slug + + Apply the `topic-paths` practice for the year and tree-root patterns. + Apply the `refs-and-switching` practice for the inventory and + tree-reading patterns. + + Algorithm: + 1. Resolve the year — `year` when given, otherwise the current year via + `current_year` + 2. Compose the topic directory prefix of the slug under the root + resolved via `resolve_history_root` — the prefix carries a trailing + slash, so a sibling slug sharing only the prefix text + ("feature-foo" of "feature-foo-bar") stays free + 3. Probe every ref of `list_branch_refs` via `read_ref_tree_paths` — + the first ref whose tree carries paths under the prefix is the + conflict + 4. No ref hosts the slug -> None + + Requirements: + - The first occupied ref wins and names the conflict; remaining refs + are not probed + - Read-only — no ref or directory is created; no checkout, no worktree + - One git invocation per ref + + Constraints: + - Do not resolve remote state over the network — the local inventory + only + - Do not probe the working copy — a topic living only on disk is the + file oracle's domain + +"DeleteTarget(topic: str, branch: str | None, remote: str | None, has_dir: bool)": + location: deletion.py + annotations: | + One identified deletion target — a topic with its hosting refs and + directory. + + `topic`: the topic slug + `branch`: the hosting local branch name, or None + `remote`: the hosting origin twin name, or None + `has_dir`: True when the topic directory of the year exists on + disk and no merged-work host carries the topic — a + merged host's tree survives the deletion, so its + directory stays out of the target + + Apply the `convention` practice for the data-model rules and + intra-package imports. + properties: + "topic -> str": | + The topic slug. + "branch -> str | None": | + The hosting local branch name, or None. + "remote -> str | None": | + The hosting origin twin name, or None. + "has_dir -> bool": | + True when the topic directory of the year exists on disk and no + merged-work host carries the topic — a merged host's tree + survives the deletion, so its directory stays out of the target. + +"resolve_delete_targets(identifiers: list[str], year: str | None = None) -> targets: list[DeleteTarget]": + location: deletion.py + annotations: | + Resolve deletion identifiers into targets — every check before any + removal. + + `identifiers`: the user inputs — branch names, topic slugs, or + their prefixes + `year`: optional year as four digits; None means the current year + `targets`: one `DeleteTarget` per identified topic, in identifier + order + + Apply the `topic-paths` practice for the year and tree patterns. + Apply the `refs-and-switching` practice for the inventory pattern. + + Algorithm: + 1. Resolve the year — `year` when given, otherwise the current year + via `current_year`; collect the branch inventory via + `list_branch_refs` and the topics of the year on disk via + `collect_history_tree` — a topic + directory no branch hosts is a targetable topic + 2. Each identifier resolves through the tiers: the exact branch + name, the exact topic slug, the prefixes of both — the first + non-empty tier wins + 3. No match -> clean error naming the identifier; several matches + -> clean error listing the candidates — no interactive choice + 4. A hosting ref whose name does not normalize into the topic + slug — a branch carrying a topic merged from another branch — + is not part of the target; when no eligible hosting ref + remains, the identifier is a clean error naming the topic and + the hosting branch — merged work is removed from the hosting + branch's tree, not deleted here. A topic carried by both an + eligible ref and a merged-work host deletes its eligible refs + but keeps its directory — the merged host's tree survives the + deletion, so the target covers the refs only. A topic directory + no branch hosts stays targetable (no refs, directory only) + 5. A local branch and its origin twin form one target; identifiers + naming one topic collapse into it + 6. The current branch — read via `resolve_current_branch_name` — + hosting any target -> clean error asking to switch away first + 7. Return the targets + + Requirements: + - Read-only — nothing is removed, created, or switched + - All-or-nothing — any unresolved or ambiguous identifier cancels + the whole call + - A topic hosted only by refs that are not its own topic branch + is a clean error naming the hosting branch — merged work is + out of scope + - A topic carried by both an eligible ref and a merged-work host + deletes its eligible refs but keeps its directory — the merged + host's tree survives the deletion + + Constraints: + - Do not resolve remote state over the network — the local + inventory only + - Do not offer an interactive selection + +"delete_topics(targets: list[DeleteTarget], year: str | None = None) -> result: str": + location: deletion.py + annotations: | + Execute the confirmed deletion of the targets — the local branch, + the origin twin, and the topic directory. + + `targets`: the resolved targets — the caller has confirmed them + `year`: optional year as four digits; None means the current year + `result`: one line describing the outcome + + Apply the `deleting` practice for the symmetric removal and the + restore-on-failure patterns. + + Algorithm: + 1. Per target, in order: a local branch exists -> capture its + commit via `resolve_ref_commit` first, then delete the local + branch via `delete_local_branch` + 2. An origin twin exists -> delete it on origin via + `delete_remote_branch`; a failed deletion restores the local + branch at the captured commit via `create_branch_at_commit` and + surfaces one clean error — the targets removed before the + failure stay removed + 3. A target with only an origin twin -> delete it on origin via + `delete_remote_branch` + 4. A target with a directory -> remove the topic directory via + `remove_topic_dir` + 5. Return the single result line + + Requirements: + - The deletion is unconditional — no merge checks; the + confirmation belongs to the caller + - The deletion push is a network operation; no fetch ever happens + + Constraints: + - Do not re-resolve the identifiers — the caller passes resolved + targets + - Do not touch topics outside `targets` + +--- + +Author: Goga +CreatedAt: 29/08/26 +Description: | + The topics domain — the cross-branch topic inventory with todo + summaries, switching with the optional todo entry, creation off an + explicit base with preflight and publication ask — quarantined + without a switch by default, checked out under the switch flag — + fast creation with publication, the ensure orchestration of the fast + process, the todo entry of a topic, and identified-topic deletion. diff --git a/goga/topics/__init__.py b/goga/topics/__init__.py new file mode 100644 index 00000000..0651644b --- /dev/null +++ b/goga/topics/__init__.py @@ -0,0 +1,53 @@ +"""Topics domain cell — the work-tracker view of the history tree. + +The cross-branch topic inventory of one year with per-topic statuses, the +switch-identifier resolution and switching orchestration, the fresh-work +creation procedure off an explicit base with its editor-sourced todo, the +todo entry of an existing topic, the fast creation-and-publication cycle +that builds a one-commit branch off an explicit base through quarantined +git plumbing and pushes it to origin while the caller stays on their +branch, the combined ensure orchestration that switches onto hosted work +and creates it when nothing hosts the identifier, and the +identified-topic deletion — the read-only target resolution and the +confirmed removal of the local branch, the origin twin, and the topic +directory. Topic identity, addressing, and statuses belong to the +history facade; git access belongs to the nested leaf cell +``goga.topics.git``; the interactive text entry — the external-editor +session every todo flows through — belongs to the nested leaf cell +``goga.topics.editor``. Mutations are local-only and happen strictly +after every decision is made — the publication push of the fast cycle +and the deletion push of the removal are the two network exceptions. +""" + +from .board import BoardRecord, collect_topic_board +from .creation import ( + check_branch_occupancy, + check_slug_occupancy, + create_topic, + enter_topic_todo, +) +from .deletion import DeleteTarget, delete_topics, resolve_delete_targets +from .ensuring import ensure_topic +from .publishing import publish_topic +from .switching import ( + SwitchCandidate, + resolve_switch_candidates, + switch_topic, +) + +__all__: list[str] = [ + "BoardRecord", + "DeleteTarget", + "SwitchCandidate", + "check_branch_occupancy", + "check_slug_occupancy", + "collect_topic_board", + "create_topic", + "delete_topics", + "ensure_topic", + "enter_topic_todo", + "publish_topic", + "resolve_delete_targets", + "resolve_switch_candidates", + "switch_topic", +] diff --git a/goga/topics/board.py b/goga/topics/board.py new file mode 100644 index 00000000..cb872ab3 --- /dev/null +++ b/goga/topics/board.py @@ -0,0 +1,360 @@ +"""The topic board of the topics domain. + +The entities declared in the cell CODEMANIFEST with ``location: board.py``: +one row of the board — a topic hosted by one branch with its todo summary — +and the read-only collector that merges the branch inventory, the ref trees +of one year, and the working copy of the current branch into the sorted +inventory of statuses and todo summaries. Git access follows the +``refs-and-switching`` patterns of the nested git cell; topic identity, +addressing, and statuses belong to the history facade. Git infrastructure +failures and the fatal scale-assembly import failure surface as +``click.ClickException`` — the clean-error boundary of the domain. +""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path + +import click + +from ..history import ( + StatusScale, + assemble_status_scale, + current_year, + normalize_topic_slug, + resolve_current_branch_name, + resolve_history_root, + resolve_topic_dir, + resolve_topic_status, + topic_exists, +) +from .git import BranchRef, list_branch_refs, read_ref_file, read_ref_tree_paths + +# One board row under construction — whether the hosting ref is +# remote-tracking, the row's maximal statuses, and the row's todo summary. +_Row = tuple[bool, list[str], str | None] + +# The topic todo file — the artifact of the ``todo`` status entry. +_TODO_FILE = "todo.md" + +# The minimum part count of a topic path — ``.goga/history/<year>/<slug>/<artifact>``. +_TOPIC_PATH_PARTS = 5 + + +@dataclass(frozen=True, kw_only=True) +class BoardRecord: + """One row of the topic board — a topic hosted by one branch. + + Attributes: + topic: The topic slug — the directory name of the topic. + branch: The display name of the hosting branch. + statuses: The qualified names of the maximal present statuses, in + scale order. + current: ``True`` when the row hosts the current working branch. + remote: ``True`` when the hosting ref is remote-tracking. + todo: The todo summary of the topic — the first line of todo.md + that yields a non-empty result after leading # markers are + stripped and the edges trimmed — or ``None`` when the topic has + no todo.md. + """ + + topic: str + branch: str + statuses: list[str] + current: bool + remote: bool + todo: str | None = None + + +def collect_topic_board(year: str | None = None, remote: bool = False) -> list[BoardRecord]: + """Collect the cross-branch topic inventory of one year with todo summaries. + + Args: + year: Optional year as four digits; ``None`` means the current year. + remote: ``True`` reads remote-tracking refs instead of local branches. + + Returns: + One ``BoardRecord`` per hosted topic, sorted by scale order of the + first maximal status, then alphabetically by topic. Read-only — no + checkout, no worktree, no mutation of any kind; the working copy is + read but never changed. A year without topics yields an empty list — + not an error. + + Algorithm: + 1. Resolve the year — ``year`` when given, otherwise the current year + 2. Assemble the status scale via ``assemble_status_scale`` once + 3. Local mode enumerates local branches via ``list_branch_refs`` and + reads the current branch from the working copy via + ``resolve_current_branch_name``; remote mode enumerates the + remote-tracking ``BranchRef`` entries of the same inventory only. + Local mode takes the full inventory of ``list_branch_refs`` — a + topic hosted only by a remote-tracking ref keeps its row with the + remote marker + 4. Read the topic tree of every ref under the root resolved via + ``resolve_history_root`` with ``read_ref_tree_paths``, without + checkout + 5. For every ref, take the topics of the resolved year with their + artifact paths and compute the maximal statuses — the working copy + via ``resolve_topic_status``, every other ref via the + ``StatusScale`` + 6. Read the todo summary of every hosted topic — the working copy + from the todo file ``todo.md`` of its directory, every other ref + from the todo.md of its ref tree via ``read_ref_file``; the + summary is the first line that yields a non-empty result after + leading # markers are stripped and the edges trimmed — the + normalization decides the choice, a line of # markers alone never + qualifies; ``None`` when the file is absent, the empty string + when no line qualifies; the file is never modified + 7. Collapse a local branch and its remote twin into one row — the + local branch wins; different branches hosting one slug stay + separate rows + 8. Mark the row hosting the current branch + 9. Sort by scale order of the first maximal status, then + alphabetically by topic, and return the records + + Requirements: + The current branch is read from the working copy — uncommitted + progress is visible; remote mode shows it through its remote twin. + + A multi-line todo.md yields its first qualifying line; a todo.md + whose every line reduces to emptiness yields the empty summary. The + todo summary never affects the sort order. + + Constraints: + Do not render — output shaping belongs to the consumer. + Do not cross the year boundary — other years are invisible here. + + Raises: + click.ClickException: a git infrastructure failure (its stderr when + git reports one, or a missing git binary), or the fatal + ``ImportError`` of the scale assembly — the broken tool package + is named in the message. + """ + try: + return _board_records(year, remote) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except ImportError as exc: + raise click.ClickException(str(exc)) from exc + + +def _board_records(year: str | None, remote: bool) -> list[BoardRecord]: + """Build the board rows of one year — the traced algorithm, unwrapped. + + Args: + year: Optional year as four digits; ``None`` means the current year. + remote: ``True`` reads remote-tracking refs instead of local branches. + + Returns: + The sorted board records of the resolved year. + """ + resolved_year = year or current_year() + scale = assemble_status_scale() + inventory = list_branch_refs() + current = resolve_current_branch_name() + refs = [ref for ref in inventory if ref.remote] if remote else inventory + prefix = _history_prefix() + topics_by_ref = _year_topics_by_ref(refs, resolved_year) + + rows: dict[tuple[str, str], _Row] = {} + + for ref in refs: + if remote or current is None or ref.name != current: + for slug, artifacts in topics_by_ref[ref.name].items(): + todo_path = f"{prefix}{resolved_year}/{slug}/{_TODO_FILE}" + rows[(slug, ref.name)] = ( + ref.remote, + scale.maximal_present(artifacts), + _todo_summary(read_ref_file(ref.name, todo_path)), + ) + continue + + hosted = _current_branch_topic(current, resolved_year, scale) + + if hosted is None: + continue + + slug, statuses, todo = hosted + rows[(slug, ref.name)] = (False, statuses, todo) + + records = [ + BoardRecord( + topic=slug, + branch=branch, + statuses=statuses, + current=_marks_current(branch, current, remote), + remote=is_remote, + todo=todo, + ) + for (slug, branch), (is_remote, statuses, todo) in _collapse_remote_twins(rows).items() + ] + + scale_order = {stage.name: index for index, stage in enumerate(scale.stages)} + records.sort(key=lambda record: (scale_order[record.statuses[0]], record.topic)) + + return records + + +def _history_prefix() -> str: + """Return the history root as a git path prefix. + + The prefix carries the trailing slash and is always posix — git + pathspecs, ``ls-tree`` output, and ``show`` paths are forward-slashed on + Windows too; a native-separator path would match nothing and silently + empty the board. + """ + return f"{resolve_history_root().as_posix()}/" + + +def _year_topics_by_ref(refs: list[BranchRef], year: str) -> dict[str, dict[str, list[str]]]: + """Read the topics of one year hosted by every given ref. + + One ``read_ref_tree_paths`` invocation per ref under the history root; + the shared entry point of the board and the switch resolution — both + walk the same ref trees without checkout. + + Args: + refs: The refs whose trees are read. + year: The resolved year as four digits. + + Returns: + The topics of the year per ref display name — ``{slug: [artifact, + ...]}`` with the artifact paths relative to the topic directory, + ready for ``StatusScale.maximal_present``. + """ + prefix = _history_prefix() + return {ref.name: _year_topics(read_ref_tree_paths(ref.name, prefix), year) for ref in refs} + + +def _year_topics(paths: list[str], year: str) -> dict[str, list[str]]: + """Split the ref-tree paths of one year into its topics. + + Args: + paths: The file paths of one ref tree, relative to the repository + root. + year: The resolved year as four digits. + + Returns: + The topics of the year — ``{slug: [artifact, ...]}`` with the + artifact paths relative to the topic directory. + """ + topics: dict[str, list[str]] = {} + + for path in paths: + parts = path.split("/") + if len(parts) < _TOPIC_PATH_PARTS or parts[2] != year: + continue + + topics.setdefault(parts[3], []).append("/".join(parts[4:])) + + return topics + + +def _current_branch_topic(current: str, year: str, scale: StatusScale) -> tuple[str, list[str], str | None] | None: + """Read the current branch's own topic from the working copy. + + The slug guard runs first: ``resolve_topic_dir`` and ``topic_exists`` + raise ``ValueError`` on an empty slug before their existence check, and + a fully non-ASCII branch name is a legal input that simply hosts no + topic. + + Args: + current: The current branch name as git reports it. + year: The resolved year as four digits. + scale: The assembled status scale. + + Returns: + The current branch's slug with its maximal statuses and its todo + summary, or ``None`` when the branch hosts no topic of the year. + """ + slug = normalize_topic_slug(current) + + if slug == "": + return None + if not topic_exists(current, year): + return None + + topic_dir = resolve_topic_dir(current, year) + todo = _todo_summary(_read_working(topic_dir / _TODO_FILE)) + + return slug, resolve_topic_status(topic_dir, scale), todo + + +def _todo_summary(content: str | None) -> str | None: + """Take the todo summary of a todo file's content. + + Args: + content: The todo file content, or ``None`` when the file is + absent. + + Returns: + The first line that yields a non-empty result after the leading # + markers are stripped and the edges trimmed, ``""`` when no line + qualifies, ``None`` for an absent file — presence differs from + absence. + """ + if content is None: + return None + + return next( + (line.lstrip("#").strip() for line in content.splitlines() if line.lstrip("#").strip()), + "", + ) + + +def _read_working(path: Path) -> str | None: + """Read one file of the working copy. + + Args: + path: The file path to read. + + Returns: + The UTF-8 file content, or ``None`` when the file is absent — + uncommitted progress is visible, a missing file is not an error. + A file a hand edit left outside UTF-8 decodes with the replacement + character instead of raising — the todo summary is display data, + never a reason to fail the board. + """ + return path.read_text(encoding="utf-8", errors="replace") if path.is_file() else None + + +def _collapse_remote_twins(rows: dict[tuple[str, str], _Row]) -> dict[tuple[str, str], _Row]: + """Drop every remote row whose local twin hosts the same topic. + + Args: + rows: The board rows keyed by ``(slug, branch display name)``. + + Returns: + The rows without the collapsed remote twins — the local branch wins. + """ + local_keys = {key for key, row in rows.items() if not row[0]} + + return {key: row for key, row in rows.items() if row[0] is False or (key[0], _short_name(key[1])) not in local_keys} + + +def _marks_current(branch: str, current: str | None, remote: bool) -> bool: + """Decide whether a row's branch hosts the current work in this mode. + + Args: + branch: The row's branch display name. + current: The current branch name, or ``None`` when there is none. + remote: Whether the board runs in remote mode. + + Returns: + ``True`` when the row hosts the current branch — by display name in + local mode, through the remote twin's short name in remote mode. + """ + if current is None: + return False + + return _short_name(branch) == current if remote else branch == current + + +def _short_name(branch: str) -> str: + """Return the branch part of a display name — after the first ``/``.""" + return branch.partition("/")[2] diff --git a/goga/topics/creation.py b/goga/topics/creation.py new file mode 100644 index 00000000..a60b6baa --- /dev/null +++ b/goga/topics/creation.py @@ -0,0 +1,578 @@ +"""The fresh-work creation and the todo entry of the topics domain. + +The entities declared in the cell CODEMANIFEST with +``location: creation.py``: the three-oracle occupancy check of a fresh-work +name, the branch-tree slug oracle that reads the topic directory of a slug +across every branch tree of the inventory — without checkout, so a topic +hosted only on a branch (or only on ``origin``) is visible — the +orchestrator that creates fresh work off an explicit base — the branch +named exactly as entered with its topic of the year: by default planted +at one quarantined commit carrying the topic todo file while the caller +stays on their branch, or — under the switch flag — planted at the base +commit and checked out together with its topic directory of the year and +its topic todo file in the working copy: a given value or the editor +session of the nested editor cell, every decision read-only before the +first input and the first mutation, every conflict one clean error, and +an optional publication ask that delegates to the fast cycle of the +publishing module — and the todo entry of a topic — the editor session +over the topic's todo.md and the write of the saved text, without a +commit. Topic identity and addressing belong to the history facade; the +bounded git mutation belongs to the nested git cell; the editor session +belongs to the nested editor cell. Git infrastructure failures surface +as ``click.ClickException`` — the clean-error boundary of the domain; +the interactive moments follow the ``click`` practice. The status scale +is never assembled here — creation is not a status consumer. +""" + +from __future__ import annotations + +import contextlib +import subprocess +import sys + +import click + +from ..history import ( + current_year, + ensure_topic_dir, + normalize_topic_slug, + resolve_current_branch_name, + resolve_history_root, + resolve_topic_file, + topic_exists, +) +from .editor import edit_text +from .git import ( + checkout_local_branch, + create_branch_at_commit, + delete_local_branch, + list_branch_refs, + read_ref_tree_paths, + resolve_ref_commit, +) + +# The board hint of an occupancy conflict — where the occupied names are +# visible to the user. +_BOARD_HINT = "run 'goga topics board' to see the board" + + +def check_branch_occupancy(branch_name: str, slug: str, year: str | None = None) -> str | None: + """Decide whether the entered branch name and the topic slug are free. + + Probes three oracles in order and returns the human-readable reason of + the first occupied one; the remaining oracles are not probed: + + 1. a local ``BranchRef`` of the inventory named exactly ``branch_name``; + 2. a remote-tracking ``BranchRef`` whose short name — the part after the + first slash of its display name — equals ``branch_name`` (the local + inventory only, no network); + 3. the topic directory of ``slug`` in the year via ``topic_exists`` — + only a directory occupies a topic. + + The git oracles check the name as entered; the history oracle checks the + slug — the two may deliberately differ (``Feature/Foo_Bar`` vs + ``feature-foo-bar``). + + Args: + branch_name: Branch name as entered (checked against the inventory). + slug: Normalized topic slug (checked against the topic directory). + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The human-readable reason of the first occupied oracle, or ``None`` + when everything is free. + + Constraints: + Read-only — no ref or directory is created. + Do not resolve remote state over the network — the local inventory + only. + + Raises: + click.ClickException: a git infrastructure failure (its stderr when + git reports one, or a missing git binary). + """ + try: + return _occupancy_conflict(branch_name, slug, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + + +def check_slug_occupancy(slug: str, year: str | None = None) -> str | None: + """Decide whether any branch of the inventory already hosts the topic directory of the slug. + + Reads the branch trees through ``read_ref_tree_paths`` — the local + branches and the remote-tracking refs as they exist locally, without + checkout — one ref at a time; the first ref whose tree carries paths + under the topic directory prefix of the slug is the conflict. A topic + hosted only on ``origin`` blocks the slug the same way a local one + does; a topic living only in the working copy does not — that is the + file oracle's domain. + + Args: + slug: Normalized topic slug (checked across every branch tree). + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The human-readable reason naming the hosting branch, or ``None`` + when no branch hosts the slug. + + Constraints: + Read-only — no ref or directory is created; no checkout, no + worktree. + Do not resolve remote state over the network — the local inventory + only. + Do not probe the working copy — a topic living only on disk is the + file oracle's domain. + + Raises: + click.ClickException: a git infrastructure failure (its stderr when + git reports one, or a missing git binary). + """ + try: + return _slug_conflict(slug, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + + +def create_topic( # noqa: PLR0913, PLR0917 — the CODEMANIFEST-declared signature + branch_name: str, + base_ref: str, + todo: str | None = None, + publish: bool = False, + commit_message: str | None = None, + year: str | None = None, + switch: bool = False, +) -> str: + """Create fresh work — a branch off an explicit base with the name as entered. + + The default path plants the branch at one quarantined commit carrying + the topic todo file — the working copy, the index, and HEAD stay + untouched and the caller stays on their branch; ``switch`` moves the + repository onto the fresh branch instead, with the topic directory + created in the working copy and the todo written uncommitted. The + publication ask may hand the work to the fast publication cycle + instead. + + Args: + branch_name: Branch name as entered by the user. + base_ref: Base revision the branch starts from — any revision + string, resolved as git resolves it (``"HEAD"`` for the + current commit). + todo: Optional multi-line todo — a non-empty value is used as + given; without a value an interactive terminal opens the + editor entry (a cancelled session leaves no todo) and a + non-interactive terminal is a clean error naming the value + option. An empty string counts as no value. + publish: ``True`` takes the publication path without the ask. + commit_message: Commit message template of the publication; + ``None`` applies the publication's own built-in default. + year: Optional year as four digits; ``None`` means the current year. + switch: ``True`` checks out the fresh branch after the creation — + the topic directory and the todo land in the working copy, + uncommitted, and the todo is optional; the default path plants + the branch at a commit carrying ``todo.md`` and needs the + todo. + + Returns: + One line describing the outcome — the created work of the normal + path or the created and published work of the fast cycle. + + Algorithm: + 1. Preflight — read-only, before any input: the empty-slug guard + via ``normalize_topic_slug``, the current-branch conflict (the + current branch already hosting the slug is a conflict — there + is no idempotent path), the occupancy oracles + ``check_branch_occupancy`` then ``check_slug_occupancy``, and + the base resolution via ``resolve_ref_commit`` + 2. Todo resolution — a non-empty value wins; without one an + interactive terminal opens the editor session via + ``edit_text`` (its cancellation leaves no todo), otherwise a + clean error naming the value option + 3. ``publish`` without a resolved todo -> clean error asking for + the todo + 4. Neither ``publish`` nor ``switch`` without a resolved todo -> + clean error — the no-switch work exists only through its + committed ``todo.md`` + 5. The ask — an interactive terminal, ``publish`` not set, a todo + resolved: ``click.confirm`` offers the publication (an empty + answer reads the default no; Ctrl-C or EOF aborts); no ask + otherwise + 6. The normal path — ``switch`` set: ``create_branch_at_commit`` + plants the branch at the base commit, ``checkout_local_branch`` + switches to it (a failed checkout rolls the plant back — the + ``publish_topic`` precedent), ``ensure_topic_dir`` creates the + topic directory of the year, and a resolved todo writes the + todo file ``todo.md`` — the write is the last action of the + path; ``switch`` unset: the quarantined plant of + ``publishing`` — one commit carrying ``todo.md`` on the base + commit, the branch planted at it, the caller stays on their + branch + 7. The publication path — the fast cycle of ``publishing`` via a + call-time import; the cycle re-runs its own preflight — the + delegation is deliberately whole + 8. Return the single result line + + Requirements: + Every decision — the preflight, the todo, the ask — precedes the + first mutation; the read-only preflight precedes any input, so a + failing base never wastes an entered todo. + The branch keeps the name as entered; the topic directory takes the + slug — the two may deliberately differ. + The todo.md file carries the todo as entered plus a single trailing + newline, encoded UTF-8 — empty lines inside the text stay as + entered. + The no-switch normal path builds its commit with the built-in + domain message — ``commit_message`` stays publication-only. + On the switch path the todo.md file is written only when a todo + resolved, and the topic directory exists before the file is + written. + The caller stays on their branch unless ``switch`` is set. + + Constraints: + Do not validate branch-name characters — git owns name validity. + Do not re-ask a conflicted name — every conflict is one clean + error. + Do not write artifact files other than the topic todo file inside + the topic directory. + + Raises: + click.ClickException: an empty slug, the current branch hosting + the slug, an occupancy conflict, an unresolvable base, no todo + without a terminal, ``publish`` or the no-switch creation + without a todo, or a git infrastructure failure (its stderr + when git reports one, or a missing git binary). + click.Abort: Ctrl-C or EOF at the publication ask. + """ + try: + return _create_topic(branch_name, base_ref, todo, publish, commit_message, year, switch) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except OSError as exc: + # ``ensure_topic_dir`` propagates the mkdir failures — a stray file + # named like the slug occupies no topic for the oracle, so the + # failure can only surface here, after the branch was created. The + # todo write shares the boundary: one clean error for both. The + # quarantined plant of the no-switch path raises its OS-level + # failures here too — one clean error covers the whole path. + raise click.ClickException(f"cannot create the topic directory or write the todo file: {exc}") from exc + + +def enter_topic_todo(topic: str, year: str | None = None) -> bool: + """Enter the todo of a topic. + + The editor session with the topic's todo.md and the write of the saved + text, without a commit. + + Args: + topic: Topic input — a branch name or an already-normalized slug. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + True when the saved text was written; False when the entry was + cancelled. + + Algorithm: + 1. Resolve the todo.md path of the topic via ``resolve_topic_file``; + an existing file provides the initial text + 2. Open the editor session via ``edit_text`` with the initial text + 3. A cancelled entry -> False — the file stays untouched + 4. The saved text -> write todo.md as entered plus a single + trailing newline, encoded UTF-8, without a commit -> True + + Requirements: + The write is the last action — nothing follows it. + The topic directory exists — directory creation belongs to the + caller. + + Constraints: + Do not create the topic directory. + Do not commit the write. + + Raises: + click.ClickException: a failed editor session (the editor cell's + own clean error), or a filesystem failure of the read or the + write. + """ + try: + return _enter_topic_todo(topic, year) + except OSError as exc: + # The boundary covers the prefill read and the saved write alike. + raise click.ClickException(f"cannot read or write the todo file: {exc}") from exc + + +def _occupancy_conflict(branch_name: str, slug: str, year: str | None) -> str | None: + """Probe the three occupancy oracles — the traced algorithm, unwrapped. + + Args: + branch_name: Branch name as entered (checked against the inventory). + slug: Normalized topic slug (checked against the topic directory). + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The reason of the first occupied oracle, or ``None``. + """ + resolved_year = year or current_year() + refs = list_branch_refs() + + if any(not ref.remote and ref.name == branch_name for ref in refs): + return f"branch '{branch_name}' already exists" + if any(ref.remote and ref.name.partition("/")[2] == branch_name for ref in refs): + return f"remote-tracking branch '{branch_name}' already exists" + + if topic_exists(slug, resolved_year): + return f"history topic '{slug}' already exists for {resolved_year}" + + return None + + +def _slug_conflict(slug: str, year: str | None) -> str | None: + """Probe the branch-tree slug oracle — the traced algorithm, unwrapped. + + Args: + slug: Normalized topic slug (checked across every branch tree). + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The reason naming the first hosting branch, or ``None``. + """ + resolved_year = year or current_year() + # The trailing slash is load-bearing: it keeps a sibling slug that only + # shares the prefix text ("feature-foo-bar" of "feature-foo") free. + prefix = f"{resolve_history_root().as_posix()}/{resolved_year}/{slug}/" + + for ref in list_branch_refs(): + if read_ref_tree_paths(ref.name, prefix): + return f"topic '{slug}' of {resolved_year} is already hosted by branch '{ref.name}'" + + return None + + +def _create_topic( # noqa: PLR0913, PLR0917 — the unwrapped mirror of the declared signature + branch_name: str, + base_ref: str, + todo: str | None, + publish: bool, + commit_message: str | None, + year: str | None, + switch: bool, +) -> str: + """Run the traced creation procedure — the unwrapped orchestration. + + Args: + branch_name: Branch name as entered by the user. + base_ref: Base revision the branch starts from. + todo: The todo as entered, or ``None``/empty for no value. + publish: ``True`` takes the publication path without the ask. + commit_message: Commit message template of the publication; + ``None`` applies the publication's own default. + year: Optional year as four digits; ``None`` means the current year. + switch: ``True`` checks out the fresh branch after the creation. + + Returns: + The single result line of the outcome. + """ + resolved_year = year or current_year() + + # The preflight — read-only and before any input: a failing base or a + # conflicted name must never waste an entered todo. + slug = normalize_topic_slug(branch_name) + if slug == "": + raise click.ClickException(f"branch name '{branch_name}' normalizes to an empty topic slug") + + current = resolve_current_branch_name() + if current is not None and normalize_topic_slug(current) == slug: + raise click.ClickException( + f"branch {current} already hosts topic {resolved_year}/{slug} — switch to it instead of re-creating it" + ) + + conflict = check_branch_occupancy(branch_name, slug, year) + if conflict is None: + conflict = check_slug_occupancy(slug, year) + if conflict is not None: + raise click.ClickException(f"{conflict} — {_BOARD_HINT}") + + base_commit = resolve_ref_commit(base_ref) + + resolved_todo = _resolve_todo(todo) + + if publish and resolved_todo is None: + raise click.ClickException("the publication needs a todo — the board reads the topic through todo.md") + + if not publish and not switch and resolved_todo is None: + # Git keeps no empty directories: without a committed todo.md the + # no-switch work exists in no tree — the board and the slug oracle + # cannot see it. The publication enforces the same for its own + # path. + raise click.ClickException( + "the local creation needs a todo — the board reads the topic through todo.md; " + "pass --todo/-t or --switch/-s to create on the spot without one" + ) + + if not _publication_asked(publish, resolved_todo): + if not switch: + # The no-switch plant goes through the same quarantined + # mechanics as the publication — the call-time import breaks + # the creation ↔ publishing import cycle exactly like the + # publication delegation below; the built-in message applies, + # ``commit_message`` stays publication-only. + from .publishing import _plant_topic_branch # noqa: PLC0415 — breaks the creation ↔ publishing import cycle + + _plant_topic_branch(branch_name, resolved_todo, base_commit, slug, resolved_year, None) + return f"Created branch {branch_name} and topic {resolved_year}/{slug}" + + _enter_fresh_branch(branch_name, base_commit, resolved_todo, year, resolved_year) + return f"Created branch {branch_name} and topic {resolved_year}/{slug}" + + # The publication delegates to the fast cycle through a call-time + # import: publishing imports this module's occupancy oracles, so a + # module-level import would be circular and crash the facade load in + # either order. The cycle re-runs its own preflight — the delegation + # is deliberately whole, no partial pre-sharing of results. + from .publishing import publish_topic # noqa: PLC0415 — breaks the creation ↔ publishing import cycle + + return publish_topic(branch_name, resolved_todo, base_ref, commit_message, year) + + +def _enter_fresh_branch( + branch_name: str, + base_commit: str, + resolved_todo: str | None, + year: str | None, + resolved_year: str, +) -> None: + """Plant the branch at the base commit and switch to it — the traced + switch path of the creation, with the topic directory and the todo + written into the working copy. + + Args: + branch_name: Branch name as entered by the user. + base_commit: The base commit hash the preflight resolved. + resolved_todo: The resolved todo text, or ``None`` for no todo. + year: The year argument as passed — ``None`` means the current + year for the directory creation. + resolved_year: Year as four digits — the directory and the todo + file segment. + """ + create_branch_at_commit(branch_name, base_commit) + try: + checkout_local_branch(branch_name) + except (subprocess.CalledProcessError, OSError): + # A failed checkout would strand the planted branch: the + # occupancy oracle blocks the retry ("already exists") and the + # deletion flow cannot remove it (a bare branch hosts no + # topic), so only a raw ``git branch -D`` recovers. Roll the + # plant back — the ``publish_topic`` precedent; a failure of + # the rollback itself is suppressed so the checkout reason + # surfaces. + with contextlib.suppress(subprocess.CalledProcessError, OSError): + delete_local_branch(branch_name) + raise + + ensure_topic_dir(branch_name, year) + if resolved_todo is not None: + _write_todo(branch_name, resolved_year, resolved_todo) + + +def _resolve_todo(todo: str | None) -> str | None: + """Resolve the todo of the fresh work — the value, the editor, or an + error. + + A non-empty value wins; without one an interactive terminal opens the + editor session (its cancellation leaves no todo), and a + non-interactive terminal is a clean error naming the value option. + + Args: + todo: The todo as entered, or ``None``/empty for no value. + + Returns: + The resolved todo text, or ``None`` when no todo accompanies the + work. + + Raises: + click.ClickException: no value and no interactive terminal. + """ + if todo: + return todo + if not sys.stdin.isatty(): + raise click.ClickException( + "the todo needs a value — pass --todo/-t or run the creation on an interactive terminal" + ) + + return edit_text() + + +def _publication_asked(publish: bool, todo: str | None) -> bool: + """Decide between the normal path and the publication — the ask. + + The ask runs only on an interactive terminal, without ``publish``, + and with a resolved todo: an empty answer reads the default no and + Ctrl-C or EOF aborts. Without the ask ``publish`` decides directly. + + Args: + publish: ``True`` takes the publication path without the ask. + todo: The resolved todo, or ``None``. + + Returns: + ``True`` when the work goes to the publication path. + """ + if not publish and todo is not None and sys.stdin.isatty(): + return click.confirm("Publish the branch to origin?") + + return publish + + +def _enter_topic_todo(topic: str, year: str | None) -> bool: + """Run the traced todo-entry procedure — the unwrapped orchestration. + + Args: + topic: Topic input — a branch name or an already-normalized slug. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + True when the saved text was written; False when the entry was + cancelled. + """ + resolved_year = year or current_year() + + path = resolve_topic_file(topic, "todo.md", resolved_year) + # The read is display data — the editor prefill — so a byte outside + # UTF-8 (a hand edit saved in another encoding) decodes with the + # replacement character: a ``UnicodeDecodeError`` is a ``ValueError``, + # it matches none of the module's handlers and would pierce the + # clean-error boundary — mirroring ``_run_git`` of the git cell. + initial = path.read_text(encoding="utf-8", errors="replace") if path.exists() else None + + saved = edit_text(initial) + + if saved is None: + return False + + _write_todo(topic, resolved_year, saved) + return True + + +def _write_todo(name: str, year: str, todo: str) -> None: + """Write the topic todo file of a topic directory. + + The file carries the todo as entered plus a single trailing newline, + encoded UTF-8 — created when absent, overwritten when present; a text + that already ends in a newline keeps exactly that one. The topic + directory must already exist; only directories are created here. + + Args: + name: Topic input — a branch name or an already-normalized slug. + year: Year as four digits. + todo: Multi-line todo text as entered — a fresh-work value or the + editor session's saved text. + """ + content = todo if todo.endswith("\n") else f"{todo}\n" + resolve_topic_file(name, "todo.md", year).write_text(content, encoding="utf-8") diff --git a/goga/topics/deletion.py b/goga/topics/deletion.py new file mode 100644 index 00000000..0e46107a --- /dev/null +++ b/goga/topics/deletion.py @@ -0,0 +1,544 @@ +"""The identified-topic deletion of the topics domain. + +The entities declared in the cell CODEMANIFEST with +``location: deletion.py``: one identified deletion target — a topic with +its hosting refs and its directory —, the read-only resolution that +maps deletion identifiers to targets, and the confirmed removal of the +resolved targets — every decision is made before the first mutation. +The resolution mirrors the switch tiers, keeps merged work out of +scope, and collapses a local branch and its origin twin into one target +assembled from the full inventory; the removal deletes the local branch, +the origin twin, and the topic directory, restoring the local branch at +its captured commit when the remote deletion fails. Topic identity and +addressing belong to the history facade; the ref inventory, the +ref-tree reading, and the branch removals belong to the nested git +cell. Git infrastructure failures surface as +``click.ClickException`` — the clean-error boundary of the domain. +""" + +from __future__ import annotations + +import contextlib +import subprocess +from dataclasses import dataclass + +import click + +from ..history import ( + collect_history_tree, + current_year, + normalize_topic_slug, + remove_topic_dir, + resolve_current_branch_name, + resolve_history_root, +) +from .board import _short_name +from .git import ( + BranchRef, + create_branch_at_commit, + delete_local_branch, + delete_remote_branch, + list_branch_refs, + read_ref_tree_paths, + resolve_ref_commit, +) + + +@dataclass(frozen=True, kw_only=True) +class DeleteTarget: + """One identified deletion target — a topic with its hosting refs and directory. + + Attributes: + topic: The topic slug. + branch: The hosting local branch name, or ``None``. + remote: The hosting origin twin name — the short name the remote + deletion consumes — or ``None``. + has_dir: ``True`` when the topic directory of the year exists on + disk. + """ + + topic: str + branch: str | None + remote: str | None + has_dir: bool + + +def resolve_delete_targets(identifiers: list[str], year: str | None = None) -> list[DeleteTarget]: + """Resolve deletion identifiers into targets — every check before any removal. + + Args: + identifiers: The user inputs — branch names, topic slugs, or their + prefixes. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + One ``DeleteTarget`` per identified topic, in identifier order. + A local branch and its origin twin form one target; repeated + identifiers naming one topic collapse into it. + + Algorithm: + 1. Resolve the year and collect the read-only inventory once: the + branch refs via ``list_branch_refs``, the topics of the year + hosted by every ref via ``read_ref_tree_paths`` under the + history root, and the topics of the year on disk via + ``collect_history_tree`` + 2. Each identifier resolves through the tiers — the exact branch + name (a local ref by its name, a remote-tracking ref by its + short name), the exact topic slug (a ref hosting it in its + tree, or a disk topic — the slug names one topic, never the + hosting ref's other topics), the prefixes of both — the first + non-empty tier wins + 3. Within the tier the distinct hosted topics decide: more than + one -> clean error listing the candidates — no interactive + choice; an empty tier names no topic and the resolution falls + through to the next — no topic in any tier -> clean error + naming the identifier (a branch nothing hosts never resolves) + 4. Merged-work guard: a hosting ref is part of the target only + when its normalized name equals the topic slug; a topic whose + every hosting ref carries it as merged work is a clean error + naming the topic and the hosting branch — a disk topic no + branch hosts stays targetable (no refs, directory only); a + topic an eligible ref and a merged-work host both carry keeps + its directory — the merged host's tree survives the deletion + 5. Assemble every identified target from the full inventory — the + local ref and the remote-tracking twin whose normalized names + equal the slug, and the disk presence — never from the tier + that matched, so the result cannot depend on identifier order; + several local refs normalizing into the slug are a clean error + naming them + 6. The current branch naming any target's branch, or its slug + naming any target's topic -> clean error asking to switch away + first + + Requirements: + Read-only — nothing is removed, created, or switched. + All-or-nothing — any unresolved or ambiguous identifier cancels + the whole call. + + Constraints: + Do not resolve remote state over the network — the local + inventory only. + Do not offer an interactive selection. + + Raises: + click.ClickException: an identifier nothing hosts, an ambiguous + identifier, merged work, several branches hosting one topic, + the current branch hosting a target, + a git infrastructure failure (its stderr when git reports + one, or a missing git binary), or an OS failure of the + history-tree read. + """ + try: + return _resolve_delete_targets(identifiers, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except OSError as exc: + raise click.ClickException(f"reading the history tree failed: {exc}") from exc + + +def _resolve_delete_targets(identifiers: list[str], year: str | None) -> list[DeleteTarget]: + """Run the traced resolution — the unwrapped orchestration. + + Args: + identifiers: The user inputs as entered. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The targets in identifier order, deduplicated by topic slug. + """ + resolved_year = year or current_year() + refs = list_branch_refs() + hosted = _hosted_slugs(refs, resolved_year) + disk = _disk_slugs(resolved_year) + + topics: list[str] = [] + + for identifier in identifiers: + topic = _identify(identifier, refs, hosted, disk) + if topic not in topics: + topics.append(topic) + + targets = [_assemble_target(topic, refs, hosted, disk) for topic in topics] + _guard_current_branch(targets) + + return targets + + +def _hosted_slugs(refs: list[BranchRef], year: str) -> dict[str, set[str]]: + """Read the topics of one year hosted by every given ref. + + One ``read_ref_tree_paths`` invocation per ref under the year prefix + of the history root — the same tree-reading pattern as the board and + the switch resolution, without checkout and without statuses: the + deletion inventory carries names only. + + Args: + refs: The refs whose trees are read. + year: The resolved year as four digits. + + Returns: + The hosted topic slugs per ref display name — an empty set for a + ref hosting nothing of the year. + """ + prefix = f"{resolve_history_root().as_posix()}/{year}/" + return {ref.name: _slugs_under(read_ref_tree_paths(ref.name, prefix), prefix) for ref in refs} + + +def _slugs_under(paths: list[str], prefix: str) -> set[str]: + """Take the topic slugs of the ref-tree paths under the year prefix. + + Args: + paths: The file paths of one ref tree, relative to the repository + root, already filtered under ``prefix`` by the reader. + prefix: The year prefix the paths sit under. + + Returns: + The distinct topic slugs — the first path segment after the + prefix. + """ + return {path.removeprefix(prefix).split("/", 1)[0] for path in paths} + + +def _disk_slugs(year: str) -> set[str]: + """Read the topics of one year found in the on-disk history tree. + + The scale-free provider of the deletion flow — statuses are never + computed, so the status registry is never touched. + + Args: + year: The resolved year as four digits. + + Returns: + The topic slugs of the year present on disk — a topic directory + no branch hosts is a targetable topic. + """ + for record in collect_history_tree(): + if record.year == year: + return set(record.topics) + + return set() + + +def _identify(identifier: str, refs: list[BranchRef], hosted: dict[str, set[str]], disk: set[str]) -> str: + """Resolve one identifier into its single topic through the tiers. + + Args: + identifier: The user input as entered. + refs: The full branch inventory. + hosted: The hosted topic slugs per ref display name. + disk: The on-disk topic slugs of the year. + + Returns: + The one identified topic slug. + + Raises: + click.ClickException: nothing matches the identifier, or several + topics match it — no interactive choice. + """ + slug = normalize_topic_slug(identifier) + tiers = ( + _tier_exact_branch(identifier, refs, hosted), + _tier_exact_slug(slug, hosted, disk), + _tier_prefix(identifier, slug, refs, hosted, disk), + ) + + for topics in tiers: + if topics is None: + continue + if len(topics) > 1: + raise click.ClickException(f"several topics match {identifier!r}: {', '.join(sorted(topics))}") + if topics: + return next(iter(topics)) + # An empty tier names no topic — fall through to the next tier. The + # exact name of a bare branch must not shadow the slug tier: right + # after a creation the branch exists while its todo.md is still + # uncommitted, so the topic lives on disk only and the exact-name + # identifier reaches it there (a branch nothing hosts never + # resolves — deletion deletes topics, not bare branches). + + raise click.ClickException(f"no topic matches {identifier!r}") + + +def _tier_exact_branch(identifier: str, refs: list[BranchRef], hosted: dict[str, set[str]]) -> set[str] | None: + """Take the first tier — the exact branch name. + + Args: + identifier: The user input as entered. + refs: The full branch inventory. + hosted: The hosted topic slugs per ref display name. + + Returns: + The distinct hosted topics of the matched refs — ``None`` when no + ref carries the name (the tier is skipped), an empty set when a + matched branch hosts nothing: the empty tier names no topic, so + the resolution falls through to the slug tier — the exact name of + a bare branch must not shadow the disk topic of the same slug + (deletion deletes topics, not bare branches: a branch nothing + hosts never resolves). + """ + matched = [ + ref + for ref in refs + if (not ref.remote and ref.name == identifier) or (ref.remote and _short_name(ref.name) == identifier) + ] + if not matched: + return None + + return set().union(*(hosted[ref.name] for ref in matched)) + + +def _tier_exact_slug(slug: str, hosted: dict[str, set[str]], disk: set[str]) -> set[str] | None: + """Take the second tier — the exact topic slug. + + Args: + slug: The normalized identifier. + hosted: The hosted topic slugs per ref display name. + disk: The on-disk topic slugs of the year. + + Returns: + The slug itself when a ref hosts it or it sits on disk — the + slug names exactly one topic, and a hosting ref's other topics + are not matches of it (the per-pair tiering of the switch + resolver) — ``None`` when neither matches (the tier is skipped). + """ + if slug == "": + return None + if any(slug in slugs for slugs in hosted.values()) or slug in disk: + return {slug} + + return None + + +def _tier_prefix( + identifier: str, slug: str, refs: list[BranchRef], hosted: dict[str, set[str]], disk: set[str] +) -> set[str] | None: + """Take the third tier — the prefixes of both. + + Args: + identifier: The user input as entered. + slug: The normalized identifier. + refs: The full branch inventory. + hosted: The hosted topic slugs per ref display name. + disk: The on-disk topic slugs of the year. + + Returns: + The hosted topics of the refs whose name starts with the + identifier, plus the hosted and disk slugs starting with the + normalized slug — ``None`` when nothing matches (the tier is + skipped). The short-name arm carries the exact tier's remote + rule: a remote-tracking ref is read by its short name, a local + branch by its full name alone — a slashed local branch's tail + never widens the prefix. A non-ASCII identifier normalizes to + the empty slug, which every slug starts with, so the + slug-prefix arms stay disabled for it. + """ + topics: set[str] = set() + matched = [ + ref + for ref in refs + if ref.name.startswith(identifier) or (ref.remote and _short_name(ref.name).startswith(identifier)) + ] + + for ref in matched: + topics |= hosted[ref.name] + + if slug != "": + topics |= {hosted_slug for slugs in hosted.values() for hosted_slug in slugs if hosted_slug.startswith(slug)} + topics |= {disk_slug for disk_slug in disk if disk_slug.startswith(slug)} + if not matched and not topics: + return None + + return topics + + +def _assemble_target(topic: str, refs: list[BranchRef], hosted: dict[str, set[str]], disk: set[str]) -> DeleteTarget: + """Assemble one topic's target from the full inventory. + + The hosting refs decide eligibility — a ref is part of the target + only when its normalized name equals the topic slug, so a branch + carrying the topic as merged work never turns into a deletion of the + integration branch. The lookup walks the full inventory, never the + tier that matched, so repeated identifiers of one topic in any order + assemble the identical target. + + Args: + topic: The identified topic slug. + refs: The full branch inventory. + hosted: The hosted topic slugs per ref display name. + disk: The on-disk topic slugs of the year. + + Returns: + The assembled target — the local branch and the origin twin short + name of the eligible refs, and the disk presence. The directory + joins the target only when no merged-work host carries the topic: + a merged host's tree survives the deletion, so removing its + working-copy directory would dirty the hosting branch's checkout + while the topic lives on in its commits. + + Raises: + click.ClickException: the topic is hosted only by refs that carry + it as merged work — the hosting branch is named in the error; + or several local refs normalize into the slug — they are all + named in the error, and one of them must go first. + """ + hosts = [ref for ref in refs if topic in hosted[ref.name]] + eligible = [ref for ref in hosts if _normalized_name(ref) == topic] + if hosts and not eligible: + names = ", ".join(ref.name for ref in hosts) + raise click.ClickException( + f"topic {topic!r} is hosted by {names} as merged work — " + "remove it from the hosting branch's tree instead of deleting" + ) + + merged = [ref for ref in hosts if _normalized_name(ref) != topic] + + # Two local refs normalizing into one slug must never pick one of + # them by inventory order — the named branch could be the one left + # behind. The error is order-independent by construction. + local_names = [ref.name for ref in eligible if not ref.remote] + if len(local_names) > 1: + names = ", ".join(local_names) + raise click.ClickException( + f"several branches host topic {topic!r}: {names} — remove all but one of them before deleting" + ) + + branch = local_names[0] if local_names else None + # The twin is the *origin* twin — the one remote the deletion push of + # the git cell addresses. A tracking ref of another remote stays an + # eligible host (never merged work), but it contributes no deletable + # twin: its short name would otherwise be pushed at origin — a wrong + # remote's branch deleted or a phantom "remote ref does not exist" + # after the local branch is already gone. + remote = next( + (_short_name(ref.name) for ref in eligible if ref.remote and ref.name.partition("/")[0] == "origin"), + None, + ) + has_dir = topic in disk and not merged + + return DeleteTarget(topic=topic, branch=branch, remote=remote, has_dir=has_dir) + + +def _normalized_name(ref: BranchRef) -> str: + """Normalize one ref's name into the topic-slug grammar. + + Args: + ref: The ref whose name is normalized. + + Returns: + The normalized name — the short name for a remote-tracking ref + (the local twin's name), the display name for a local branch. + """ + return normalize_topic_slug(_short_name(ref.name) if ref.remote else ref.name) + + +def _guard_current_branch(targets: list[DeleteTarget]) -> None: + """Reject a deletion that would remove the current branch's topic. + + Args: + targets: The assembled targets. + + Raises: + click.ClickException: the current branch names a target's branch, + or its slug names a target's topic — the deletion needs a + switch away first. + """ + current = resolve_current_branch_name() + if current is None: + return + slug = normalize_topic_slug(current) + + for target in targets: + if target.branch == current or slug == target.topic: + raise click.ClickException(f"the current branch hosts topic {target.topic!r} — switch away before deleting") + + +def delete_topics(targets: list[DeleteTarget], year: str | None = None) -> str: + """Delete confirmed targets — the local branch, the origin twin, and the topic directory of each. + + Args: + targets: The confirmed targets, as resolved by + ``resolve_delete_targets``. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + One line reporting the removal. + + Algorithm: + 1. Resolve the year once + 2. Per target, in order: capture the hosting local branch's + commit, delete the local branch, delete the origin twin, then + remove the topic directory — on a remote failure restore the + local branch at the captured commit before the error surfaces + 3. Return the single outcome line + + Requirements: + The commit is captured before the local deletion — after it the + name no longer resolves. + + Targets removed before a failure stay removed; a failed remote + deletion restores the failing target's local branch at the + captured commit, and a failure of the restore itself is + suppressed so the original remote reason surfaces. + + The directory removal is idempotent on absence — a missing + directory is not an error. + + Constraints: + Do not confirm, check merges, or re-resolve — the caller resolved + the targets and owns the confirmation. + + Raises: + click.ClickException: a git infrastructure failure (its stderr + when git reports one, or a missing git binary), or an OS + failure of the removal. + """ + try: + return _delete_topics(targets, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except OSError as exc: + raise click.ClickException(f"cannot complete the deletion: {exc}") from exc + + +def _delete_topics(targets: list[DeleteTarget], year: str | None) -> str: + """Run the traced removal — the unwrapped orchestration. + + Args: + targets: The confirmed targets, in deletion order. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The single result line of the outcome. + """ + resolved_year = year or current_year() + + for target in targets: + commit: str | None = None + if target.branch is not None: + commit = resolve_ref_commit(target.branch) + delete_local_branch(target.branch) + if target.remote is not None: + try: + delete_remote_branch(target.remote) + except (subprocess.CalledProcessError, OSError): + # Restore the local branch at the captured commit before the + # error propagates — a failure of the restore itself is + # suppressed so the original remote reason surfaces (the + # ``publish_topic`` precedent). A remote-only target has + # nothing to restore; targets removed before this one stay + # removed. + if target.branch is not None: + with contextlib.suppress(subprocess.CalledProcessError, OSError): + create_branch_at_commit(target.branch, commit) + raise + if target.has_dir: + remove_topic_dir(target.topic, resolved_year) + + slugs = ", ".join(target.topic for target in targets) + return f"Deleted {len(targets)} topic(s) of {resolved_year}: {slugs}" diff --git a/goga/topics/editor/.usages/editor-entry.md b/goga/topics/editor/.usages/editor-entry.md new file mode 100644 index 00000000..bd435a1a --- /dev/null +++ b/goga/topics/editor/.usages/editor-entry.md @@ -0,0 +1,36 @@ +# topics/editor — the editor entry session + +How to collect or edit a multi-line text through the user's external +editor with the `goga.topics.editor` facade. For consumers that need +interactive text entry in the topics domain: the topics orchestrations. + +The session is the only interactive moment — the caller decides when it +happens. Cancellation is a normal outcome, not an error: the caller +continues as without the entry. + +## Entering a fresh text + + from goga.topics.editor import edit_text + + text = edit_text() + if text is None: + ... # cancelled — continue as without the entry + +- The editor resolves $VISUAL, then $EDITOR, then vi. +- An empty saved file (or only blank lines) cancels the entry. +- The saved text comes back as entered — the caller owns any write. + +## Editing an existing text + + text = edit_text(initial="Fix retries.\n\nIgnore the cap.") + +- The session starts from the existing content; saving without changes + cancels the entry — the existing text is the caller's to keep. + +## Errors + +- A missing interactive terminal, a failed editor run, or an + interrupted session raise a clean error before any mutation — the + caller's state is untouched. +- In tests never launch a real editor: mock the editor with a script + that writes into the file, leaves it, clears it, or exits non-zero. diff --git a/goga/topics/editor/CODEMANIFEST b/goga/topics/editor/CODEMANIFEST new file mode 100644 index 00000000..ae384f12 --- /dev/null +++ b/goga/topics/editor/CODEMANIFEST @@ -0,0 +1,79 @@ +Usages: + convention: .goga/usages/conventions.md + editor: .goga/usages/cooks/editor.md + click: .goga/usages/cooks/click.md + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + Use the `editor` practice for the entry protocol: the editor chain, + the temporary file, the cancellation rule, the error cases, and the + test mock of the editor. + Use the `click` practice for the editor-launching facility of the + library and the clean error style. + + This cell owns access to the external editor for the topics domain: + the interactive collection and editing of a multi-line text through + the editor resolved by the entry protocol. The session is the single + interactive surface — every orchestration moment, the decision when + an entry happens, belongs to the caller. It is environment access, + not topic logic. Use relative imports. + +--- + +"edit_text(initial: str | None = None) -> text: str | None": + location: entry.py + annotations: | + Collect or edit a multi-line text in the external editor. + + `initial`: the text the session starts from — the existing content + when an entry edits; None or an empty string starts from + an empty entry + `text`: the saved text as entered, or None when the entry was + cancelled + + Apply the `editor` practice for the entry protocol. + Apply the `click` practice for the editor-launching facility and + the clean error style. + Apply the `convention` practice for docstring style and + intra-package imports. + + Algorithm: + 1. No interactive terminal -> clean error, before anything else + 2. Print the hint to the terminal — an empty saved file cancels the + entry — before the editor starts + 3. Run the editor session over a temporary file — empty for a + fresh entry, carrying `initial` plus a missing trailing newline + (the editor facility of the click library appends one to its + prefill) otherwise — per the `editor` practice + 4. A failed editor run or an interrupted session -> clean error, + nothing mutated + 5. The saved content blank or equal to the prefilled text -> + cancellation: return None + 6. Return the saved text as entered + + Requirements: + - The session touches nothing but its temporary file — no project + state is read or mutated + - The hint precedes the editor start + - The text is returned as entered — no normalization, no trailing + newline added + + Constraints: + - Do not place hint comments inside the file itself + - Do not validate the content — every non-blank text is accepted + - Do not write the result anywhere — the write belongs to the + caller + +--- + +Author: Goga +CreatedAt: 02/09/26 +Description: | + Access to the external editor for the topics domain — the + interactive multi-line text entry session. diff --git a/goga/topics/editor/__init__.py b/goga/topics/editor/__init__.py new file mode 100644 index 00000000..5db5c4c3 --- /dev/null +++ b/goga/topics/editor/__init__.py @@ -0,0 +1,12 @@ +"""Editor-access cell for the topics domain. + +The interactive multi-line text entry session through the external +editor resolved by the ``editor`` practice — the single interactive +surface of the topics domain. It is environment access, not topic +logic — every decision about when an entry happens belongs to the +caller. +""" + +from .entry import edit_text + +__all__: list[str] = ["edit_text"] diff --git a/goga/topics/editor/entry.py b/goga/topics/editor/entry.py new file mode 100644 index 00000000..5ee1a19a --- /dev/null +++ b/goga/topics/editor/entry.py @@ -0,0 +1,83 @@ +"""The external-editor entry session of the topics-domain editor cell. + +The entity declared in the cell CODEMANIFEST with +``location: entry.py``: the interactive collection and editing of a +multi-line text through the editor resolved by the ``editor`` +practice. The session is the single interactive surface of the topics +domain — every orchestration moment, the decision when an entry +happens, belongs to the caller. It is environment access, not topic +logic. +""" + +from __future__ import annotations + +import sys + +import click + +_HINT = "Enter the text. An empty or unchanged file cancels the entry." + + +def edit_text(initial: str | None = None) -> str | None: + """Collect or edit a multi-line text in the external editor. + + Args: + initial: The text the session starts from — the existing content + when an entry edits; None or an empty string starts from an + empty entry. + + Returns: + The saved text as entered, or None when the entry was cancelled. + + Algorithm: + 1. No interactive terminal -> a clean error, before anything + else + 2. Print the hint to the terminal — an empty saved file cancels + the entry — before the editor starts + 3. Run the editor session over a temporary file — empty for a + fresh entry, carrying ``initial`` plus a missing trailing + newline (the editor facility of the click library appends + one to its prefill) otherwise + 4. A failed editor run or an interrupted session -> a clean + error, nothing mutated + 5. The saved content blank or equal to the prefilled text -> + cancellation: return None + 6. Return the saved text as entered + + Requirements: + The session touches nothing but its temporary file — no project + state is read or mutated. + + The hint precedes the editor start. + + The text is returned as entered — no normalization, no trailing + newline added. + + Constraints: + Do not place hint comments inside the file itself. + + Do not validate the content — every non-blank text is accepted. + + Do not write the result anywhere — the write belongs to the + caller. + + Raises: + click.ClickException: No interactive terminal is attached, or + the editor run failed — the caller's state is untouched. + """ + if not sys.stdin.isatty(): + raise click.ClickException("the entry needs an interactive terminal") + + click.echo(_HINT) + + start = initial or "" + + if start and not start.endswith("\n"): + start += "\n" + + saved = click.edit(text=start) + + if saved is None or not saved.strip() or saved == start: + return None + + return saved diff --git a/goga/topics/ensuring.py b/goga/topics/ensuring.py new file mode 100644 index 00000000..f38f52d6 --- /dev/null +++ b/goga/topics/ensuring.py @@ -0,0 +1,241 @@ +"""The ensure orchestration of the topics domain. + +The entity declared in the cell CODEMANIFEST with +``location: ensuring.py``: the combined orchestrator that brings the +repository onto the requested work — by switching when a branch hosts the +identifier, by the fast creation from the current HEAD when nothing does; +with the todo flag the todo entry of the ensured work runs after the +switch or the creation. The resolution and the switch orchestration belong +to the switching module; the occupancy oracles and the todo entry belong +to the creation module; the topic-directory creation belongs to the +history facade; the bounded git mutation belongs to the nested git cell. +Git infrastructure failures and the fatal scale-assembly ``ImportError`` +surface as ``click.ClickException`` — the clean-error boundary of the +domain; the interactive moments follow the ``click`` practice. +""" + +from __future__ import annotations + +import subprocess +import sys + +import click + +from ..history import ( + current_year, + ensure_topic_dir, + normalize_topic_slug, + resolve_current_branch_name, +) +from .board import _short_name +from .creation import ( + _BOARD_HINT, + check_branch_occupancy, + check_slug_occupancy, + enter_topic_todo, +) +from .git import create_and_switch_branch +from .switching import SwitchCandidate, resolve_switch_candidates, switch_topic + + +def ensure_topic(identifier: str, todo: bool = False, year: str | None = None) -> str: + """Bring the repository onto the requested work, creating it when nothing hosts the identifier. + + With the todo flag, enter the todo of the work after the switch or the + creation. + + Args: + identifier: The user input — a branch name, a topic slug, or their + prefix. + todo: ``True`` enters the todo of the ensured work. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + One line describing the outcome — the switch line of the delegated + switch orchestration or the creation line of the fast creation. + + Algorithm: + 1. ``todo`` without an interactive terminal -> clean error before + any action + 2. Resolve the candidates via ``resolve_switch_candidates`` + 3. No candidate -> the fast creation from the current HEAD: the + slug guard and the occupancy oracles are clean errors, then the + branch named as entered is created and switched to via + ``create_and_switch_branch``, the topic directory of the year is + created via ``ensure_topic_dir``, and with ``todo`` the todo of + the fresh topic is entered — the entry starts only after the + switch + 4. Otherwise -> the switch orchestration via ``switch_topic`` + without the entry; with ``todo`` the hosted topic comes from the + step-2 resolution candidate whose branch is the current branch + read via ``resolve_current_branch_name`` (a remote-tracking + candidate matches by its short name): a hosted topic is entered + via ``enter_topic_todo``; a hosting branch without one gets its + topic directory created via ``ensure_topic_dir`` — an empty slug + of its name is a clean error — then the fresh entry + 5. Return the single result line + + Requirements: + Creation happens only at zero candidates — a resolvable identifier + never creates anything. + The creation always starts from the current HEAD — the + configuration base is never read here. + With ``todo``, no step follows the todo write. + Every mutation is local — no network, no fetch, no push. + The result is exactly one line. + + Constraints: + Do not ask about publication — the fast process publishes nothing. + Do not manage the stages of the hosting pipeline — continuation + belongs to the pipeline itself. + + Raises: + click.ClickException: ``todo`` without an interactive terminal, an + unusable (empty-slug) or occupied name of the fast creation, + several candidates without an interactive terminal, a dirty + working tree on a switch mutation, a git infrastructure failure + (its stderr when git reports one, or a missing git binary), an + OS failure of the topic-directory creation or the todo write, + or the fatal ``ImportError`` of the scale assembly. + click.Abort: Ctrl-C or EOF at a selection prompt — the repository + is left untouched. + """ + try: + return _ensure_topic(identifier, todo, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except ImportError as exc: + raise click.ClickException(str(exc)) from exc + except OSError as exc: + # ``ensure_topic_dir`` propagates the mkdir failures — the same + # boundary ``create_topic`` keeps for its directory creation and + # todo write, so the pipeline-driven path pierces no further than + # the CLI one (``FileNotFoundError``, an ``OSError`` subclass, is + # handled above as the missing git binary). + raise click.ClickException(f"cannot create the topic directory or write the todo file: {exc}") from exc + + +def _ensure_topic(identifier: str, todo: bool, year: str | None) -> str: + """Run the traced ensure procedure — the unwrapped orchestration. + + Args: + identifier: The user input as entered. + todo: ``True`` enters the todo of the ensured work. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The single result line of the outcome. + """ + if todo and not sys.stdin.isatty(): + raise click.ClickException("the todo entry needs an interactive terminal") + + candidates = resolve_switch_candidates(identifier, year) + + if not candidates: + return _create_fresh_work(identifier, todo, year) + + line = switch_topic(identifier, todo=False, year=year) + + if todo: + _enter_switched_todo(candidates, year) + + return line + + +def _create_fresh_work(identifier: str, todo: bool, year: str | None) -> str: + """Create the fresh work off the current HEAD — the zero-candidate path. + + The branch keeps the name as entered and starts at git's default start + point (the current HEAD); the topic directory takes the normalized + slug. The decisions — the slug guard and the occupancy oracles — + precede the first mutation; the todo entry starts only after the + switch. + + Args: + identifier: The user input as entered — becomes the branch name. + todo: ``True`` enters the todo of the fresh topic after the switch. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The creation line — the branch as entered and the topic of the + normalized slug. + """ + resolved_year = year or current_year() + + slug = normalize_topic_slug(identifier) + if slug == "": + raise click.ClickException(f"branch name '{identifier}' normalizes to an empty topic slug") + + conflict = check_branch_occupancy(identifier, slug, year) + if conflict is None: + conflict = check_slug_occupancy(slug, year) + if conflict is not None: + raise click.ClickException(f"{conflict} — {_BOARD_HINT}") + + create_and_switch_branch(identifier) + ensure_topic_dir(identifier, year) + + if todo: + enter_topic_todo(identifier, year) + + return f"Created branch {identifier} and topic {resolved_year}/{slug}" + + +def _enter_switched_todo(candidates: list[SwitchCandidate], year: str | None) -> None: + """Enter the todo of the switched work — the post-switch todo path. + + The hosted topic comes from the step-2 resolution candidate whose + branch is the current branch, never from the normalized current-branch + name: a topic merged into another branch is entered as itself, and no + directory of the hosting branch's name is created. A hosting branch + without a topic gets its topic directory created first — the fresh + entry needs a place to land — unless its name normalizes to an empty + slug, which is a clean error (the history facade's ``ValueError`` on + an empty slug never escapes the module). + + Args: + candidates: The step-2 resolution candidates — the topic lookup + never depends on which candidate the switch chose. + year: Optional year as four digits; ``None`` means the current year. + """ + current = resolve_current_branch_name() + + topic = _hosted_topic_of_current(candidates, current) + + if topic is not None: + enter_topic_todo(topic, year) + return + + if current is None or normalize_topic_slug(current) == "": + raise click.ClickException(f"branch name '{current}' normalizes to an empty topic slug") + + ensure_topic_dir(current, year) + enter_topic_todo(current, year) + + +def _hosted_topic_of_current(candidates: list[SwitchCandidate], current: str | None) -> str | None: + """Find the hosted topic of the current branch among the candidates. + + A local candidate matches by its full branch name; a remote-tracking + candidate matches by its short name — the local branch the switch + created from it. The first candidate of the resolution order wins: a + branch hosting several topics contributes its first entry, the same + choice the switch orchestration's own todo path makes. + + Args: + candidates: The step-2 resolution candidates. + current: The current branch name, or ``None`` when there is none. + + Returns: + The hosted topic slug of the matching candidate, or ``None`` when + the current branch hosts none of the candidates. + """ + for candidate in candidates: + hosted_by = _short_name(candidate.branch) if candidate.remote else candidate.branch + if hosted_by == current: + return candidate.topic + + return None diff --git a/goga/topics/git/.usages/deleting.md b/goga/topics/git/.usages/deleting.md new file mode 100644 index 00000000..e66380cf --- /dev/null +++ b/goga/topics/git/.usages/deleting.md @@ -0,0 +1,43 @@ +# topics/git — deleting a branch on origin + +How to remove a branch from the origin remote (and its local twin) with +the `goga.topics.git` facade. For consumers that tear down published +work: the topics domain, higher-level orchestration. + +The deletion is unconditional after the caller's confirmation — no +merge checks, no force flags. Every policy decision — when deletion is +allowed, what a failed deletion means — belongs to the caller. + +## Deleting a branch on origin + + from goga.topics.git import delete_remote_branch + + delete_remote_branch("feature-foo") # gone from origin + +- The deletion push is a network operation of the cell — the other one + publishes branches; no fetch ever happens. +- The local branch and the working copy stay untouched. +- A git failure surfaces as a clean error carrying the reason. + +## Deleting a local and remote pair with restore + + from goga.topics.git import ( + create_branch_at_commit, + delete_local_branch, + delete_remote_branch, + resolve_ref_commit, + ) + + commit = resolve_ref_commit("feature-foo") # capture BEFORE deletion + delete_local_branch("feature-foo") + try: + delete_remote_branch("feature-foo") + except Exception: + create_branch_at_commit("feature-foo", commit) # restore + raise + +- Capture the commit before the local deletion — after it the name no + longer resolves. +- A failed remote deletion leaves the pair recoverable: the local + branch is restored at the captured commit and the error propagates — + the caller decides the reporting. diff --git a/goga/topics/git/.usages/publishing.md b/goga/topics/git/.usages/publishing.md new file mode 100644 index 00000000..3d7d9e22 --- /dev/null +++ b/goga/topics/git/.usages/publishing.md @@ -0,0 +1,88 @@ +# topics/git — building and publishing a branch + +How to create a committed branch off a base and publish it to origin with +the `goga.topics.git` facade, without touching the working copy. For +consumers that start work on behalf of the user while the user stays on +their branch: the topics domain, higher-level orchestration. + +The quarantined path never touches the working copy, the repository index, +or HEAD — a dirty tree and a detached HEAD do not interfere. The push to +origin and the deletion push are the network operations of the cell; +everything else is local. Every +policy decision — when to roll back, what a conflict means — belongs to +the caller. + +## Resolving a base + +```python +from goga.topics.git import resolve_ref_commit + +commit = resolve_ref_commit("origin/main") # any rev string +``` + +- The revision resolves as git resolves it — a branch, a remote-tracking + ref, a tag, or a hash; a local branch is a valid base. +- An unresolvable revision is a clean error carrying the git reason — + resolve the base before any mutation. +- Read-only, no fetch — a stale remote-tracking base is the caller's + accepted condition. + +## Building a commit without the working copy + +```python +from goga.topics.git import commit_file_on_base + +commit = commit_file_on_base( + base, # from resolve_ref_commit + ".goga/history/2026/feature-foo/todo.md", # repo-root-relative + "Fix payment retries.\n\nRetries ignore the cap.\n", # content as text + "goga: create topic feature-foo", # final message +) +``` + +- One commit that adds exactly one file on top of the parent — the tree is + built in a temporary index isolated from the repository; nothing + persists after the call and no temporary directories appear outside + .git. +- The message is final — placeholders belong to the caller. +- The commit carries the repository git identity; an unset identity is a + clean git error. +- The commit exists only as a hash — no branch points to it until the + caller plants one. + +## Planting and publishing a branch + +```python +from goga.topics.git import ( + create_branch_at_commit, + origin_configured, + push_branch, +) + +if not origin_configured(): + ... # clean error: origin is not configured — before any mutation + +create_branch_at_commit("Feature/Foo_Bar", commit) # name verbatim, no switch +push_branch("Feature/Foo_Bar") # push -u origin +``` + +- `create_branch_at_commit` takes the branch name exactly as entered and + leaves the working copy on its current branch. +- `push_branch` publishes exactly the named branch and binds its upstream + — later push and pull need no arguments; the local branch stays in the + repository. +- Probe `origin_configured` before creating anything — the probe is + read-only and never raises. + +## Rolling back + +```python +from goga.topics.git import delete_local_branch + +delete_local_branch("Feature/Foo_Bar") # full rollback of a failed publication +``` + +- `delete_local_branch` removes the local branch ref; the working copy, the + index, and HEAD stay untouched — nothing else was ever mutated. +- The cell never decides to roll back — a git failure of the push + propagates with its message; the caller owns the failure policy. diff --git a/goga/topics/git/.usages/refs-and-switching.md b/goga/topics/git/.usages/refs-and-switching.md new file mode 100644 index 00000000..b97794ce --- /dev/null +++ b/goga/topics/git/.usages/refs-and-switching.md @@ -0,0 +1,87 @@ +# topics/git — refs and branch switching + +How to enumerate branch refs, read ref trees, and switch branches with the +`goga.topics.git` facade. For consumers building inventories and switching +work: the topics domain, higher-level orchestration. + +The cell is pure git access — read-only inventory plus the bounded local +mutation set. Every policy decision (when to check cleanliness, which branch +wins a duplicate, what a conflict means) belongs to the caller. + +## Enumerating branch refs + +```python +from goga.topics.git import list_branch_refs + +refs = list_branch_refs() +for ref in refs: + print(ref.name, "remote" if ref.remote else "local") +``` + +- Local branches and remote-tracking refs come back in one list, sorted by + display name; a local branch and its remote twin are two distinct refs. +- Read-only, no network. + +## Reading a ref tree + +```python +from goga.topics.git import read_ref_tree_paths + +prefix = ".goga/history/" # the history tree root, repo-root-relative +paths = read_ref_tree_paths("feature-foo", prefix) +``` + +- Paths come back relative to the repository root; no checkout, no + worktree, no temp directory — the working copy stays untouched. +- One git invocation per ref; a ref or prefix without matches yields an + empty list. + +## Reading one file of a ref + +```python +from goga.topics.git import read_ref_file + +path = ".goga/history/2026/feature-foo/todo.md" # repo-root-relative +content = read_ref_file("feature-foo", path) +if content is not None: + first = next( + (line.lstrip("#").strip() for line in content.splitlines() if line.lstrip("#").strip()), + "", + ) + print(first) +``` + +- Returns the file content as text, or None when the file cannot be read + at the ref — an absent file is the normal case, not an error; any other + git failure reads the same way. +- One git invocation per file; no checkout, no worktree, no temp + directory — the working copy stays untouched. + +## Switching branches + +```python +from goga.topics.git import ( + BranchRef, + checkout_local_branch, + create_and_switch_branch, + create_branch_from_remote_tracking, + is_working_tree_clean, +) + +if is_working_tree_clean(): + checkout_local_branch("feature-foo") # existing local branch + create_and_switch_branch("Feature/Foo_Bar") # fresh work, name verbatim + +remote_ref = BranchRef(name="origin/feature-foo", remote=True) +create_branch_from_remote_tracking(remote_ref) # remote-only host +``` + +- `checkout_local_branch` takes a short local branch name; the branch must + exist. +- `create_and_switch_branch` takes the branch name exactly as entered — no + normalization, no suffixing; git owns name validity. +- `create_branch_from_remote_tracking` takes a remote `BranchRef` obtained + from `list_branch_refs` and creates a local branch with its short name at + the ref's commit — no network. +- Probe cleanliness with `is_working_tree_clean` before any mutation — the + cell never decides on its own. diff --git a/goga/topics/git/CODEMANIFEST b/goga/topics/git/CODEMANIFEST new file mode 100644 index 00000000..9a89ffe8 --- /dev/null +++ b/goga/topics/git/CODEMANIFEST @@ -0,0 +1,446 @@ +Usages: + convention: .goga/usages/conventions.md + git: | + External git binary invoked via subprocess.run (check=True, capture_output=True). + Set GIT_TERMINAL_PROMPT=0 in the env to suppress interactive prompts. + Read-only inspection (branch refs, ref tree paths and file contents, + revision resolution, working tree state, the origin remote probe), + host-side mutations (checkout of a local branch, creating a local branch + from a remote-tracking ref, create-and-switch to a new branch), and + quarantined publication (building a commit in a temporary index held by + the GIT_INDEX_FILE env var of a single invocation — read-tree of the + base, hash-object -w of the blob, update-index --add, write-tree, + commit-tree -p; creating a branch at a commit and deleting a branch via + update-ref; pushing a branch to origin with -u), and remote deletion + (deleting a branch on origin via push origin --delete). A single-file content + read decodes UTF-8 explicitly and maps every git failure to None — the + content is display data; every other invocation propagates its git + error. Mock the subprocess call in tests per `convention`. + +Annotations: | + The `convention` practice is used for: + - Working with the codebase + - Organizing the REPL development cycle + - Debugging and testing + - Organizing the test infrastructure + - Understanding the general principles and rules of development and testing in the project + + This cell owns git access for the topics domain: enumerating branch + refs, reading the file paths and the file contents of a ref tree, + resolving a revision into its commit, and the bounded set of host-side + branch mutations — checking out a local branch, creating a local branch + from a remote-tracking ref, creating and switching to a new branch, + creating a branch at a commit without switching, deleting a local branch, + deleting a branch on the origin remote, and the working-tree cleanliness + probe. The quarantined creation path builds its commits in a temporary + index isolated from the working copy — the working tree, the index, and + HEAD stay untouched; two network operations exist — publishing a branch + to origin and deleting a branch on origin. It is environment + access, not topic logic — every decision belongs to the caller. All git + access flows through the `git` practice; mock the subprocess call in + tests per `convention`. Use relative imports. + +--- + +"BranchRef(name: str, remote: bool)": + location: refs.py + annotations: | + One branch ref of the repository inventory — a local branch or a + remote-tracking ref. + + `name`: the display name — the short branch name for a local ref, + <remote>/<branch> for a remote-tracking ref + `remote`: True when the ref is remote-tracking + + Apply the `convention` practice for the data-model rules and + intra-package imports. + + Requirements: + - The display name is the identity used by consumers — no reshortening, + no normalization + properties: + "name -> str": | + The display branch name of the ref. + "remote -> bool": | + True when the ref is a remote-tracking ref. + +"list_branch_refs() -> refs: list[BranchRef]": + location: refs.py + annotations: | + Enumerate the branch refs of the repository — local branches and + remote-tracking refs together. + + `refs`: every branch ref, sorted alphabetically by display name + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git for the local branch refs + 2. Ask git for the remote-tracking refs + 3. Drop the <remote>/HEAD symrefs — they are pointers, not branches + 4. Merge both into one inventory sorted alphabetically by display name + + Requirements: + - Read-only — no ref is created, moved, or deleted + - No network — remote-tracking refs as they exist locally + + Constraints: + - Do not deduplicate — a local branch and its remote twin are two + distinct refs here; collapsing them belongs to the caller + +"read_ref_tree_paths(ref: str, prefix: str) -> paths: list[str]": + location: trees.py + annotations: | + Read the file paths of one ref tree under a path prefix — without + checkout, worktree, or temporary directories. + + `ref`: the ref to read — a display branch name as carried by + `BranchRef` + `prefix`: the path prefix to read under, relative to the repository + root + `paths`: every file path under the prefix, relative to the repository + root + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git for the recursive file listing of the `ref` tree + 2. Keep the paths that sit under `prefix` + 3. Return them in the order git reports + + Requirements: + - One git invocation per ref + - Read-only — the working copy, the index, and .git stay untouched + - A ref or prefix without matches yields an empty list — not an error + - Both sides of the read are anchored at the repository root — the + invocation reads the same tree and reports the same paths from any + working directory inside the repository + + Constraints: + - Do not materialize the tree — no checkout, no worktree, no temp + directory + - Do not inspect file contents — paths only + +"read_ref_file(ref: str, path: str) -> content: str | None": + location: trees.py + annotations: | + Read the content of one file of a ref tree — without checkout, worktree, + or temporary directories. + + `ref`: the ref to read — a display branch name as carried by `BranchRef` + `path`: the file path to read, relative to the repository root + `content`: the file content as text, or None when git cannot read the + file at the ref + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git for the content of `path` at the `ref` + 2. A failed read yields None — not an error: the content is display + data, never a reason to fail the reader + 3. Return the content as text + + Requirements: + - One git invocation per file + - Read-only — the working copy, the index, and .git stay untouched + - Every git failure of the read yields None — an absent file at the + ref, an unknown ref, or any other git error are indistinguishable + to the caller + - The content is returned as-is — no interpretation, no transformation + + Constraints: + - Do not materialize the tree — no checkout, no worktree, no temp + directory + - Do not list paths — path enumeration belongs to `read_ref_tree_paths` + +"checkout_local_branch(branch: str)": + location: switch.py + annotations: | + Switch the working copy to an existing local branch. + + `branch`: the short name of the local branch + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to check out the branch + 2. A git failure surfaces as a clean error + + Requirements: + - The mutation is local — no network, no push, no fetch + + Constraints: + - Do not create the branch — it must exist + - Do not decide when a switch is allowed — the caller owns the + cleanliness policy + +"create_branch_from_remote_tracking(ref: BranchRef)": + location: switch.py + annotations: | + Create a local branch from a remote-tracking ref and switch to it. + + `ref`: the remote-tracking ref to branch from — the local branch takes + its short name + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to create a local branch named after the short name of `ref` + at the ref's commit and switch to it + 2. A git failure surfaces as a clean error + + Requirements: + - The mutation is local — the remote-tracking ref as it exists locally, + no network + + Constraints: + - Do not update the remote-tracking ref — no fetch + +"create_and_switch_branch(branch_name: str)": + location: switch.py + annotations: | + Create a branch with the name exactly as entered and switch to it. + + `branch_name`: the branch name as entered by the user + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to create the branch named exactly `branch_name` and switch + to it + 2. A name git rejects surfaces as a clean error + + Requirements: + - The name is taken verbatim — no normalization, no suffixing + - The mutation is local + + Constraints: + - Do not validate the name characters — git owns name validity + +"is_working_tree_clean() -> clean: bool": + location: switch.py + annotations: | + Probe whether the working copy carries uncommitted changes. + + `clean`: True when the working tree and the index match the branch head + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git for the working tree state + 2. Report the answer as a plain boolean + + Requirements: + - Read-only — nothing is staged, committed, or reset + + Constraints: + - Do not act on a dirty tree — the caller owns the policy + +"resolve_ref_commit(ref: str) -> commit: str": + location: publish.py + annotations: | + Resolve a revision string into the commit it names. + + `ref`: any revision string — a branch name, a remote-tracking ref, a + tag, or a commit hash, resolved as git resolves it + `commit`: the commit hash the revision names — an annotated tag is + peeled to its commit, so the hash is usable as the parent of + a commit-tree build + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to resolve `ref` into its commit + 2. An unresolvable revision surfaces as a clean error carrying the git + reason + + Requirements: + - Read-only — no ref is created, moved, or deleted + - No network — the revision resolves against the local repository state + + Constraints: + - Do not fetch — a stale remote-tracking base is the caller's accepted + condition + - Do not decide what a usable base is — the caller owns the policy + +"commit_file_on_base(base: str, path: str, content: str, message: str) -> commit: str": + location: publish.py + annotations: | + Build one commit that adds a single file on top of a parent commit — + without touching the working copy. + + `base`: the parent commit hash + `path`: the file path to add, relative to the repository root + `content`: the file content as text + `message`: the final commit message + `commit`: the hash of the built commit + + Apply the `git` practice for the quarantined index pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Build the tree of the commit in a temporary index quarantined from + the repository — the parent tree, the new blob of `path` staged over + it, and the resulting tree written out + 2. Ask git to create the commit with `message` on the parent `base`, + authored by the repository git identity + 3. Return the new commit hash; every git failure — an unreadable + parent, an unset identity — surfaces as a clean error + + Requirements: + - The working copy, the repository index, and HEAD stay untouched + - The temporary index lives only inside the environment of a single git + invocation — nothing persists after the build + - No temporary directories or files are created outside .git + + Constraints: + - Do not create, move, or delete branches — the commit exists only as a + hash until the caller plants it + - Do not write the file to the working copy + +"create_branch_at_commit(branch_name: str, commit: str)": + location: publish.py + annotations: | + Create a branch at a commit without switching to it. + + `branch_name`: the branch name as entered by the user + `commit`: the commit the branch points to + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to create the branch `branch_name` pointing at `commit`, + leaving the working copy on its current branch + 2. A git failure surfaces as a clean error + + Requirements: + - The name is taken verbatim — no normalization, no suffixing + - The mutation is local — no network + - The working copy, the index, and HEAD stay untouched — no switch + happens + + Constraints: + - Do not validate the name characters — git owns name validity + +"delete_local_branch(branch_name: str)": + location: publish.py + annotations: | + Delete a local branch. + + `branch_name`: the short name of the local branch + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to delete the local branch ref + 2. A git failure surfaces as a clean error + + Requirements: + - The deletion is local — no network + - The working copy, the index, and HEAD stay untouched — a branch not + checked out is deletable without a switch + + Constraints: + - Do not decide whether deletion is safe — the caller owns the rollback + policy + +"delete_remote_branch(branch_name: str)": + location: publish.py + annotations: | + Delete a branch on the origin remote. + + `branch_name`: the short name of the branch on origin + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to delete the branch on the origin remote + 2. A git failure surfaces as a clean error carrying the reason + + Requirements: + - Exactly the named branch — no other branches or tags + - The deletion is a network operation — the local branch and the + working copy stay untouched + + Constraints: + - Do not retry or roll back — the caller owns the failure policy + +"push_branch(branch_name: str)": + location: publish.py + annotations: | + Publish a branch to the origin remote with upstream binding. + + `branch_name`: the short name of the local branch + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git to push the branch to origin and bind its upstream + 2. A git failure propagates with its message — the caller owns the + rollback + + Requirements: + - The push is a network operation of the topics domain + - The local branch stays in the repository after the push + + Constraints: + - Do not push other branches or tags — exactly the named branch + - Do not retry or roll back — the caller owns the failure policy + +"origin_configured() -> configured: bool": + location: publish.py + annotations: | + Probe whether the origin remote is configured. + + `configured`: True when the repository has an origin remote + + Apply the `git` practice for the invocation pattern. + Apply the `convention` practice for docstring style and intra-package + imports. + + Algorithm: + 1. Ask git for the origin remote URL + 2. Report the answer as a plain boolean + + Requirements: + - Read-only — no remote state is contacted, no network + - Strict as a probe result: an absent or unreadable origin reads False — + the probe never raises + + Constraints: + - Do not tolerate the absence into a success — the caller turns False + into its own clean error + +--- + +Author: Goga +CreatedAt: 29/08/26 +Description: | + Git access for the topics domain — branch refs, ref tree reading, + revision resolution, host-side branch mutations, quarantined branch + construction with publication, and remote branch deletion. diff --git a/goga/topics/git/__init__.py b/goga/topics/git/__init__.py new file mode 100644 index 00000000..13fcd624 --- /dev/null +++ b/goga/topics/git/__init__.py @@ -0,0 +1,50 @@ +"""Git-access cell for the topics domain. + +The branch-ref inventory, the file-path reading of a ref tree, the file +contents of a ref tree, the bounded set of host-side branch mutations +— checking out a local branch, creating a local branch from a +remote-tracking ref, create-and-switch to a new branch, and the +working-tree cleanliness probe — and the quarantined publication: +resolving a revision into its commit, building one commit over a base +through a temporary index without touching the working copy, planting and +deleting a branch without switching, the two network operations of the +cell — pushing a branch to origin with upstream binding and deleting a +branch on the origin remote — and the origin probe. It is environment +access, not topic logic — every decision belongs to the caller. +""" + +from .publish import ( + commit_file_on_base, + create_branch_at_commit, + delete_local_branch, + delete_remote_branch, + origin_configured, + push_branch, + resolve_ref_commit, +) +from .refs import BranchRef, list_branch_refs +from .switch import ( + checkout_local_branch, + create_and_switch_branch, + create_branch_from_remote_tracking, + is_working_tree_clean, +) +from .trees import read_ref_file, read_ref_tree_paths + +__all__: list[str] = [ + "BranchRef", + "checkout_local_branch", + "commit_file_on_base", + "create_and_switch_branch", + "create_branch_at_commit", + "create_branch_from_remote_tracking", + "delete_local_branch", + "delete_remote_branch", + "is_working_tree_clean", + "list_branch_refs", + "origin_configured", + "push_branch", + "read_ref_file", + "read_ref_tree_paths", + "resolve_ref_commit", +] diff --git a/goga/topics/git/publish.py b/goga/topics/git/publish.py new file mode 100644 index 00000000..3def2190 --- /dev/null +++ b/goga/topics/git/publish.py @@ -0,0 +1,355 @@ +"""The quarantined publication of the topics-domain git cell. + +The entities declared in the cell CODEMANIFEST with +``location: publish.py``: revision resolution, the quarantined building of +one commit that adds a single file on top of a parent commit, planting a +branch at a commit without switching, deleting a local branch, deleting a +branch on the origin remote, pushing a branch to origin with upstream +binding, and the strict origin probe. The +quarantined path never touches the working copy, the repository index, or +HEAD — a dirty tree and a detached HEAD do not interfere. Every git +invocation follows the ``git`` practice. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + + +def resolve_ref_commit(ref: str) -> str: + """Resolve a revision string into the commit it names. + + Args: + ref: Any revision string — a branch name, a remote-tracking ref, a + tag, or a commit hash, resolved as git resolves it. + + Returns: + The commit hash the revision names — annotated tags peeled to + their commit, so the hash is usable as ``commit-tree -p`` parent. + + Algorithm: + 1. Ask git to resolve ``ref`` into its commit + 2. An unresolvable revision surfaces as a clean error carrying the + git reason + + Requirements: + Read-only — no ref is created, moved, or deleted. + + No network — the revision resolves against the local repository + state. + + Constraints: + Do not fetch — a stale remote-tracking base is the caller's + accepted condition. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + resolution itself (propagated raw — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + result = _run_git(["git", "rev-parse", "--verify", f"{ref}^{{commit}}"]) + return result.stdout.strip() + + +def commit_file_on_base(base: str, path: str, content: str, message: str) -> str: + """Build one commit that adds a single file on top of a parent commit — without touching the working copy. + + Args: + base: The parent commit hash. + path: The file path to add, relative to the repository root. + content: The file content as text. + message: The final commit message. + + Returns: + The hash of the built commit. + + Algorithm: + 1. Build the tree of the commit in a temporary index quarantined + from the repository — the parent tree, the new blob of ``path`` + staged over it, and the resulting tree written out + 2. Ask git to create the commit with ``message`` on the parent + ``base``, authored by the repository git identity + 3. Return the new commit hash; every git failure — an unreadable + parent, an unset identity — surfaces as a clean error + + Requirements: + The working copy, the repository index, and HEAD stay untouched. + + The temporary index lives only inside the environment of a single + git invocation — nothing persists after the build. + + No temporary directories or files are created outside ``.git``. + + Constraints: + Do not create, move, or delete branches — the commit exists only + as a hash until the caller plants it. + + Do not write the file to the working copy. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + chain itself (propagated raw — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + git_dir = _run_git(["git", "rev-parse", "--git-dir"]).stdout.strip() + fd, name = tempfile.mkstemp(dir=git_dir, prefix="goga-publish-index-") + os.close(fd) + index = Path(name) + + try: + _run_git(["git", "read-tree", base], index=index) + blob = _run_git(["git", "hash-object", "-w", "--stdin"], input=content).stdout.strip() + _run_git(["git", "update-index", "--add", "--cacheinfo", f"100644,{blob},{path}"], index=index) + tree = _run_git(["git", "write-tree"], index=index).stdout.strip() + return _run_git(["git", "commit-tree", tree, "-p", base, "-m", message]).stdout.strip() + finally: + index.unlink(missing_ok=True) + + +def create_branch_at_commit(branch_name: str, commit: str) -> None: + """Create a branch at a commit without switching to it. + + Args: + branch_name: The branch name as entered by the user. + commit: The commit the branch points to. + + Algorithm: + 1. Ask git to create the branch ``branch_name`` pointing at + ``commit``, leaving the working copy on its current branch + 2. A git failure surfaces as a clean error + + Requirements: + The name is taken verbatim — no normalization, no suffixing. + + The mutation is local — no network. + + The working copy, the index, and HEAD stay untouched — no switch + happens. + + Constraints: + Do not validate the name characters — git owns name validity. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + branch creation itself (propagated raw — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + # The create-only form is load-bearing: a plain ``update-ref <ref> + # <commit>`` moves an existing ref without complaint, and the occupancy + # oracle can miss one — git lengthens the display name of ``refs/heads/v1`` + # to ``heads/v1`` when a tag of the same name exists, and a concurrent + # writer can plant the name between the oracle and the plant. The moved + # branch would then be deleted by the caller's rollback — real work lost + # behind a push error. ``create`` refuses an existing ref (``reference + # already exists``), so the failure surfaces as one clean error before + # anything is mutated; the stdin stream never parses the verbatim name as + # an option, dash-leading or not. + # + # The ``-z`` framing is load-bearing the same way: the line-oriented + # stream splits on ``LF``, so a newline inside a machine-generated name + # would open a *second* command of the same transaction — ``create + # refs/heads/x <oid>`` followed by ``update refs/heads/main <oid>`` moves + # the user's branch behind a garbled error. Under NUL delimiters the name + # stays one token, so the verbatim name reaches git's own refname + # validation — the contract that git owns name validity — and a control + # character dies as ``invalid ref format`` before anything is mutated. + _run_git( + ["git", "update-ref", "--stdin", "-z"], + input=f"create refs/heads/{branch_name}\0{commit}\0", + ) + + +def delete_local_branch(branch_name: str) -> None: + """Delete a local branch. + + Args: + branch_name: The short name of the local branch. + + Algorithm: + 1. Ask git to delete the local branch ref + 2. A git failure surfaces as a clean error + + Requirements: + The deletion is local — no network. + + The working copy, the index, and HEAD stay untouched — a branch + not checked out is deletable without a switch. + + Constraints: + Do not decide whether deletion is safe — the caller owns the + rollback policy. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + deletion itself (propagated raw — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + _run_git(["git", "update-ref", "-d", f"refs/heads/{branch_name}"]) + + +def delete_remote_branch(branch_name: str) -> None: + """Delete a branch on the origin remote. + + Args: + branch_name: The short name of the branch on origin. + + Algorithm: + 1. Ask git to delete the branch on the origin remote + 2. A git failure surfaces as a clean error carrying the reason + + Requirements: + Exactly the named branch — no other branches or tags. + + The deletion is a network operation — the local branch and the + working copy stay untouched. + + Constraints: + Do not retry or roll back — the caller owns the failure policy. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + deletion push itself (propagated raw — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + # The full refspec is load-bearing exactly as in ``push_branch``: a + # short name that starts with a dash (git accepts + # ``refs/heads/--mirror``, and the plant creates names verbatim) would + # be parsed as a push option — after ``--delete`` a bare ``--mirror`` + # does not name a branch anymore, and ``--repo`` or ``--all`` would act + # at all. The refspec can never start with a dash, so exactly the named + # branch goes. + _run_git(["git", "push", "origin", "--delete", f"refs/heads/{branch_name}"]) + + +def push_branch(branch_name: str) -> None: + """Publish a branch to the origin remote with upstream binding. + + Args: + branch_name: The short name of the local branch. + + Algorithm: + 1. Ask git to push the branch to origin and bind its upstream + 2. A git failure propagates with its message — the caller owns the + rollback + + Requirements: + The push is a network operation of the topics domain. + + The local branch stays in the repository after the push. + + Constraints: + Do not push other branches or tags — exactly the named branch. + + Do not retry or roll back — the caller owns the failure policy. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + push itself (propagated raw — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + # The full refspec is load-bearing: a bare name that starts with a dash + # (git accepts ``refs/heads/--mirror``, and the plant creates it verbatim) + # would be parsed as a push option — ``--mirror`` would sync and prune + # every remote ref while ``--delete`` or ``--repo`` would act at all. The + # refspec can never start with a dash, so exactly the named branch goes. + # ``--no-follow-tags`` holds that no-tags line even under the user's + # ``push.followTags`` config — a local-only annotated tag on the base + # commit would otherwise ride along with the branch. + _run_git( + [ + "git", + "push", + "--no-follow-tags", + "-u", + "origin", + f"refs/heads/{branch_name}:refs/heads/{branch_name}", + ] + ) + + +def origin_configured() -> bool: + """Probe whether the origin remote is configured. + + Returns: + True when the repository has an origin remote. + + Algorithm: + 1. Ask git for the origin remote URL + 2. Report the answer as a plain boolean + + Requirements: + Read-only — no remote state is contacted, no network. + + Strict as a probe result: an absent or unreadable origin reads + False — the probe never raises. + + Constraints: + Do not tolerate the absence into a success — the caller turns + False into its own clean error. + """ + try: + _run_git(["git", "remote", "get-url", "origin"]) + except (subprocess.CalledProcessError, FileNotFoundError): + return False + return True + + +def _run_git( + command: list[str], + *, + input: str | None = None, + index: Path | None = None, +) -> subprocess.CompletedProcess[str]: + """Run one git invocation following the ``git`` practice. + + Args: + command: The argv of the invocation, starting with ``git``. + input: The text to feed the invocation on stdin, if any. + index: The path of a quarantined index — exported to the single + invocation through the ``GIT_INDEX_FILE`` env var, never + written to the repository index. + + Returns: + The completed invocation with captured text output. + """ + # The output is display data — hashes and git messages — so a byte a + # remote hook left outside UTF-8 decodes with the replacement character + # instead of raising from inside ``subprocess.run``: a + # ``UnicodeDecodeError`` is a ``ValueError``, it matches none of the + # domain's handlers, so it would pierce the clean-error boundary and + # skip the caller's rollback — mirroring ``read_ref_file``. + # + # The devnull stdin is load-bearing: without it every invocation + # inherits the caller's stdin, and a git subcommand that falls back to + # reading its operand from stdin would block on a descriptor that never + # reaches EOF — a terminal, an open pipe under a harness. ``commit-tree + # -m ""`` does exactly that (an empty ``-m`` counts as "no message + # supplied"), so an explicit empty template hung the publish cycle + # forever. No invocation of this module consumes the caller's stdin: + # the two that need stdin (``hash-object --stdin``, + # ``update-ref --stdin``) pass ``input`` explicitly, which routes them + # through a pipe instead. + return subprocess.run( + command, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + input=input, + stdin=subprocess.DEVNULL if input is None else None, + env={ + **os.environ, + "GIT_TERMINAL_PROMPT": "0", + **({"GIT_INDEX_FILE": str(index)} if index is not None else {}), + }, + ) diff --git a/goga/topics/git/refs.py b/goga/topics/git/refs.py new file mode 100644 index 00000000..220531fe --- /dev/null +++ b/goga/topics/git/refs.py @@ -0,0 +1,98 @@ +"""The branch-ref inventory of the topics-domain git cell. + +The entities declared in the cell CODEMANIFEST with ``location: refs.py``: +one branch ref of the repository inventory — a local branch or a +remote-tracking ref — and the read-only enumerator that merges both kinds +into one alphabetically sorted inventory. Every git invocation follows the +``git`` practice — ``subprocess.run`` with ``check=True``, captured output, +and ``GIT_TERMINAL_PROMPT=0`` in the environment. +""" + +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass + + +@dataclass(frozen=True, kw_only=True) +class BranchRef: + """One branch ref of the repository inventory. + + Attributes: + name: The display name — the short branch name for a local ref, + ``<remote>/<branch>`` for a remote-tracking ref. + remote: ``True`` when the ref is remote-tracking. + + Requirements: + The display name is the identity used by consumers — no + reshortening, no normalization. + """ + + name: str + remote: bool + + +def list_branch_refs() -> list[BranchRef]: + """Enumerate the branch refs of the repository. + + Asks git for the local branches and the remote-tracking refs (as they + exist locally — no network), drops the ``*/HEAD`` symrefs, and merges + both answers into one inventory sorted alphabetically by display name. + A local branch and its remote twin stay two distinct refs — collapsing + them belongs to the caller. + + Returns: + Every branch ref, sorted alphabetically by display name. + + Algorithm: + 1. Ask git for the local branch refs + 2. Ask git for the remote-tracking refs + 3. Merge both into one inventory sorted alphabetically by display + name + + Requirements: + Read-only — no ref is created, moved, or deleted. + + No network — remote-tracking refs as they exist locally. + + Constraints: + Do not deduplicate — a local branch and its remote twin are two + distinct refs here; collapsing them belongs to the caller. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + ref listing itself (propagated — the caller wraps it). + OSError: unexpected OS-level failures of the git invocations (e.g. a + missing git binary). + """ + local = _refs_under("refs/heads", remote=False) + tracked = _refs_under("refs/remotes", remote=True) + return sorted([*local, *tracked], key=lambda ref: ref.name) + + +def _refs_under(ref_prefix: str, remote: bool) -> list[BranchRef]: + """Run one ``for-each-ref`` invocation and parse it into branch refs. + + Args: + ref_prefix: The ref namespace to list — ``refs/heads`` or + ``refs/remotes``. + remote: Whether the listed refs are remote-tracking. + + Returns: + The parsed refs of the namespace, in git order. Refs whose display + name ends with ``/HEAD`` (the ``<remote>/HEAD`` symrefs) are not + branches and are dropped. + """ + result = subprocess.run( + ["git", "for-each-ref", "--format=%(refname:short)", ref_prefix], + check=True, + capture_output=True, + text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + return [ + BranchRef(name=line, remote=remote) + for line in result.stdout.splitlines() + if line and not line.endswith("/HEAD") + ] diff --git a/goga/topics/git/switch.py b/goga/topics/git/switch.py new file mode 100644 index 00000000..f60dd975 --- /dev/null +++ b/goga/topics/git/switch.py @@ -0,0 +1,146 @@ +"""The branch mutations of the topics-domain git cell. + +The entities declared in the cell CODEMANIFEST with +``location: switch.py``: checking out an existing local branch, creating +a local branch from a remote-tracking ref, create-and-switch to a new +branch, and the working-tree cleanliness probe. They are bounded +host-side git actions — when a switch is allowed stays with the caller. +Every git invocation follows the ``git`` practice. +""" + +from __future__ import annotations + +import os +import subprocess + +from .refs import BranchRef + + +def checkout_local_branch(branch: str) -> None: + """Switch the working copy to an existing local branch. + + Args: + branch: The short name of the local branch. + + Algorithm: + 1. Ask git to check out the branch + 2. A git failure surfaces as a clean error + + Requirements: + The mutation is local — no network, no push, no fetch. + + Constraints: + Do not create the branch — it must exist. + + Do not decide when a switch is allowed — the caller owns the + cleanliness policy. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + checkout itself (propagated — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + _run_git(["git", "switch", branch]) + + +def create_branch_from_remote_tracking(ref: BranchRef) -> None: + """Create a local branch from a remote-tracking ref and switch to it. + + The local branch takes the short name of the ref — the part after the + first slash of its display name. + + Args: + ref: The remote-tracking ref to branch from. + + Algorithm: + 1. Ask git to create a local branch named after the short name of + ``ref`` at the ref's commit and switch to it + 2. A git failure surfaces as a clean error + + Requirements: + The mutation is local — the remote-tracking ref as it exists + locally, no network. + + Constraints: + Do not update the remote-tracking ref — no fetch. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + branch creation itself (propagated — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + short = ref.name.partition("/")[2] + _run_git(["git", "switch", "-c", short, ref.name]) + + +def create_and_switch_branch(branch_name: str) -> None: + """Create a branch with the name exactly as entered and switch to it. + + Args: + branch_name: The branch name as entered by the user. + + Algorithm: + 1. Ask git to create the branch named exactly ``branch_name`` and + switch to it + 2. A name git rejects surfaces as a clean error + + Requirements: + The name is taken verbatim — no normalization, no suffixing. + + The mutation is local. + + Constraints: + Do not validate the name characters — git owns name validity. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + branch creation itself (propagated — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + _run_git(["git", "switch", "-c", branch_name]) + + +def is_working_tree_clean() -> bool: + """Probe whether the working copy carries uncommitted changes. + + Returns: + True when the working tree and the index match the branch head. + + Algorithm: + 1. Ask git for the working tree state + 2. Report the answer as a plain boolean + + Requirements: + Read-only — nothing is staged, committed, or reset. + + Constraints: + Do not act on a dirty tree — the caller owns the policy. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + state probe itself (propagated — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + return _run_git(["git", "status", "--porcelain"]).stdout.strip() == "" + + +def _run_git(command: list[str]) -> subprocess.CompletedProcess[str]: + """Run one git invocation following the ``git`` practice. + + Args: + command: The argv of the invocation, starting with ``git``. + + Returns: + The completed invocation with captured text output. + """ + return subprocess.run( + command, + check=True, + capture_output=True, + text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) diff --git a/goga/topics/git/trees.py b/goga/topics/git/trees.py new file mode 100644 index 00000000..f5d25f8b --- /dev/null +++ b/goga/topics/git/trees.py @@ -0,0 +1,139 @@ +"""The ref-tree reading of the topics-domain git cell. + +The entities declared in the cell CODEMANIFEST with ``location: trees.py``: +the file paths of one ref tree under a path prefix, and the file contents +of one file of a ref tree. ``ls-tree`` and ``show`` walk the object +database of the repository — no checkout, no worktree, no temporary +directory — and every git invocation follows the ``git`` practice. +""" + +from __future__ import annotations + +import os +import subprocess + + +def read_ref_tree_paths(ref: str, prefix: str) -> list[str]: + """Read the file paths of one ref tree under a path prefix. + + Args: + ref: The ref to read — a display branch name as carried by + :class:`~goga.topics.git.refs.BranchRef`. + prefix: The path prefix to read under, relative to the repository + root. + + Returns: + Every file path under the prefix, relative to the repository root, + in the order git reports. + + Algorithm: + 1. Ask git for the recursive file listing of the ``ref`` tree + 2. Keep the paths that sit under ``prefix`` + 3. Return them in the order git reports + + Requirements: + One git invocation per ref. + + Read-only — the working copy, the index, and ``.git`` stay + untouched. + + A ref or prefix without matches yields an empty list — not an + error. + + Both sides of the read are anchored at the repository root — the + invocation reads the same tree from any working directory inside + the repository. + + Constraints: + Do not materialize the tree — no checkout, no worktree, no temp + directory. + + Do not inspect file contents — paths only. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + tree listing itself (propagated — the caller wraps it). + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + result = subprocess.run( + # The ``--`` separator precedes the ref: a display name that starts + # with a dash (git accepts ``refs/heads/--mirror``) would otherwise + # be parsed as an ls-tree option and fail the whole inventory read. + # ``--full-name`` and the ``:/`` pathspec magic anchor both sides of + # the read at the repository root: without them git resolves the + # pathspec against the working directory and reports paths relative + # to it, so a caller inside a subdirectory would read nothing while + # the publication write (``--cacheinfo``) lands at the root + # regardless — the occupancy oracle would miss the very conflict it + # exists to catch. + ["git", "ls-tree", "-r", "--name-only", "--full-name", "--", ref, f":/{prefix}"], + check=True, + capture_output=True, + text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + return [path for path in result.stdout.splitlines() if path and path.startswith(prefix)] + + +def read_ref_file(ref: str, path: str) -> str | None: + """Read the content of one file of a ref tree. + + Args: + ref: The ref to read — a display branch name as carried by + :class:`~goga.topics.git.refs.BranchRef`. + path: The file path to read, relative to the repository root. + + Returns: + The file content as text, or None when git cannot read the + file at the ref — an absent file or any other git failure. + + Algorithm: + 1. Ask git for the content of ``path`` at the ``ref`` + 2. A failed read yields None — not an error: the content is + display data, never a reason to fail the reader + 3. Return the content as text + + Requirements: + One git invocation per file. + + Every git failure of the read yields None — an absent file at + the ref, an unknown ref, or any other git error are + indistinguishable to the caller. + + Read-only — the working copy, the index, and ``.git`` stay + untouched. + + The content is returned as-is — no interpretation, no + transformation. The content is UTF-8 by the creation contract, so + the invocation decodes UTF-8 explicitly — locale decoding breaks + on non-ASCII content under the C/POSIX locale. A file a hand edit + left outside UTF-8 decodes with the replacement character instead + of raising — the content is display data, never a reason to fail + the reader. + + Constraints: + Do not materialize the tree — no checkout, no worktree, no temp + directory. + + Do not list paths — path enumeration belongs to + :func:`read_ref_tree_paths`. + + Raises: + OSError: unexpected OS-level failures of the git invocation (e.g. a + missing git binary). + """ + try: + result = subprocess.run( + ["git", "show", f"{ref}:{path}"], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + except subprocess.CalledProcessError: + return None + + return result.stdout diff --git a/goga/topics/publishing.py b/goga/topics/publishing.py new file mode 100644 index 00000000..be85f814 --- /dev/null +++ b/goga/topics/publishing.py @@ -0,0 +1,216 @@ +"""The fast creation-and-publication of the topics domain. + +The entity declared in the cell CODEMANIFEST with +``location: publishing.py``: the fast cycle that creates fresh work and +publishes it in one go — a branch off an explicit base carrying exactly one +commit with the topic todo file, pushed to origin, while the caller stays +on their branch. Every decision is made before the first mutation; every +conflict of the decision chain is one clean error — there is no re-ask; +the mutation sequence is the quarantined commit build, the branch plant, +and the push, and a failed publication rolls back fully — the planted +branch is deleted and nothing else was ever mutated. The commit message +default lives here as the built-in domain template. The quarantined +commit build and the branch plant also serve the no-switch creation of +``creation`` through the shared plant helper. The occupancy oracles +belong to ``creation``; the bounded git mutations to the nested git cell. +Git infrastructure failures surface as ``click.ClickException`` — the +clean-error boundary of the domain. +""" + +from __future__ import annotations + +import contextlib +import subprocess + +import click + +from ..history import ( + current_year, + normalize_topic_slug, + resolve_current_branch_name, + resolve_topic_file, +) +from .creation import _BOARD_HINT, check_branch_occupancy, check_slug_occupancy +from .git import ( + commit_file_on_base, + create_branch_at_commit, + delete_local_branch, + origin_configured, + push_branch, + resolve_ref_commit, +) + +# The built-in commit message template of the fast path — the ``{slug}`` +# placeholder is replaced with the topic slug. The domain owns the default, +# so every caller (the CLI flags, the configuration section) may omit the +# template. +_DEFAULT_COMMIT_MESSAGE = "goga: create topic {slug}" + + +def publish_topic( + branch_name: str, + todo: str, + base_ref: str, + commit_message: str | None = None, + year: str | None = None, +) -> str: + """Create fresh work and publish it. + + A branch off an explicit base carrying one commit with the topic todo, + pushed to origin, while the caller stays on their branch. + + Args: + branch_name: Branch name as entered by the user. + todo: The multi-line todo of the fresh work — written to the topic + todo file todo.md as entered plus a single trailing newline + (exactly one — a todo already ending in a newline keeps it); + required and non-empty, an empty todo is a clean error asking + for it. + base_ref: Base revision the branch starts from — any revision + string, resolved as git resolves it. + commit_message: Commit message template — the ``{slug}`` + placeholder is replaced with the topic slug; a template + without the placeholder is used as is; ``None`` applies the + built-in default ``goga: create topic {slug}``. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + One line describing the created and published work. + + Raises: + click.ClickException: an empty slug, an empty todo, the current + branch already hosting the slug, an occupancy conflict, a + missing origin remote, a git infrastructure failure (its + stderr when git reports one, or a missing git binary). + """ + try: + return _publish_topic(branch_name, todo, base_ref, commit_message, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except OSError as exc: + # An OS-level failure can strike at any phase — the quarantined + # chain creating or removing its temporary index under ``.git``, or a + # git invocation failing to spawn — so the message stays + # phase-neutral; it surfaces here as one clean error instead of a + # raw traceback, mirroring ``create_topic``. + raise click.ClickException(f"cannot complete the publication: {exc}") from exc + + +def _publish_topic( + branch_name: str, + todo: str, + base_ref: str, + commit_message: str | None, + year: str | None, +) -> str: + """Run the traced fast cycle — the unwrapped orchestration. + + Args: + branch_name: Branch name as entered by the user. + todo: The multi-line todo of the fresh work as entered by the user. + base_ref: Base revision the branch starts from. + commit_message: Commit message template with ``{slug}`` optional; + ``None`` applies the built-in default. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The single result line of the outcome. + """ + resolved_year = year or current_year() + + slug = normalize_topic_slug(branch_name) + if slug == "": + raise click.ClickException(f"branch name '{branch_name}' normalizes to an empty topic slug") + + if not todo: + raise click.ClickException("the fast path needs a non-empty todo — pass the text or enter it interactively") + + current = resolve_current_branch_name() + if current is not None and normalize_topic_slug(current) == slug: + raise click.ClickException( + f"branch {current} already hosts topic {resolved_year}/{slug} — the fast path is only for fresh work" + ) + + conflict = check_branch_occupancy(branch_name, slug, resolved_year) + if conflict is None: + conflict = check_slug_occupancy(slug, resolved_year) + if conflict is not None: + raise click.ClickException(f"{conflict} — {_BOARD_HINT}") + + if not origin_configured(): + raise click.ClickException("origin is not configured — the fast mode publishes to origin") + + base_commit = resolve_ref_commit(base_ref) + + _plant_topic_branch(branch_name, todo, base_commit, slug, resolved_year, commit_message) + + try: + push_branch(branch_name) + except (subprocess.CalledProcessError, OSError): + # Full rollback before the one clean error — a git failure and a + # spawn-level OS failure of the push alike leave nothing of this + # cycle behind. A failure of the rollback itself is suppressed so + # the original push reason surfaces; a branch left behind stays + # visible on the board. + with contextlib.suppress(subprocess.CalledProcessError, OSError): + delete_local_branch(branch_name) + raise + + return f"Created branch {branch_name} and published topic {resolved_year}/{slug}" + + +def _plant_topic_branch( # noqa: PLR0913, PLR0917 — the shared plant step of the two commit-building paths + branch_name: str, + todo: str, + base_commit: str, + slug: str, + resolved_year: str, + commit_message: str | None, +) -> str: + """Plant the branch at one quarantined commit carrying the topic todo file. + + The shared step of the two commit-building paths — the fast + publication and the no-switch creation of ``creation``: the commit is + built through quarantined git plumbing over ``base_commit`` and the + branch is planted at it, without touching the working copy, the index, + or HEAD. + + Args: + branch_name: Branch name as entered by the user. + todo: The todo text as entered by the user. + base_commit: The parent commit hash the commit is built on. + slug: The normalized topic slug — the topic directory of the todo + file and the ``{slug}`` placeholder value. + resolved_year: Year as four digits — the topic directory segment. + commit_message: Commit message template — the ``{slug}`` + placeholder is replaced with the topic slug; a template + without the placeholder is used as is; ``None`` applies the + built-in default ``goga: create topic {slug}``. + + Returns: + The hash of the built commit the branch was planted at. + + Raises: + subprocess.CalledProcessError: a git infrastructure failure of the + chain itself (propagated raw — the caller wraps it). + OSError: unexpected OS-level failures of the chain (e.g. a missing + git binary or a quarantined-index failure under ``.git``). + """ + # The single-trailing-newline rule: an editor-sourced todo already ends + # with a newline (click's read-back), so an unconditional append would + # publish a blank trailing line; a bare value gains exactly one. + content = todo if todo.endswith("\n") else todo + "\n" + message = commit_message if commit_message is not None else _DEFAULT_COMMIT_MESSAGE + path = resolve_topic_file(slug, "todo.md", resolved_year).as_posix() + commit = commit_file_on_base( + base_commit, + path, + content, + message.replace("{slug}", slug), + ) + + create_branch_at_commit(branch_name, commit) + return commit diff --git a/goga/topics/switching.py b/goga/topics/switching.py new file mode 100644 index 00000000..c318aadf --- /dev/null +++ b/goga/topics/switching.py @@ -0,0 +1,436 @@ +"""The switch resolution and orchestration of the topics domain. + +The entities declared in the cell CODEMANIFEST with +``location: switching.py``: one candidate of a switch-identifier +resolution, the read-only resolver walking the same ref trees as the +board, and the orchestrator that brings the repository onto the chosen +host branch by purely switching — with the todo flag it enters the todo +of the switched topic through the entry of ``creation.py`` after the +switch. Topic identity and statuses belong to the +history facade; the bounded git mutations belong to the nested git cell. +Git infrastructure failures and the fatal scale-assembly ``ImportError`` +surface as ``click.ClickException`` — the clean-error boundary of the +domain; the interactive moments follow the ``click`` practice. +""" + +from __future__ import annotations + +import subprocess +import sys +from dataclasses import dataclass + +import click + +from ..history import ( + StatusScale, + assemble_status_scale, + current_year, + normalize_topic_slug, + resolve_current_branch_name, +) +from .board import _current_branch_topic, _short_name, _year_topics_by_ref +from .creation import enter_topic_todo +from .git import ( + BranchRef, + checkout_local_branch, + create_branch_from_remote_tracking, + is_working_tree_clean, + list_branch_refs, +) + + +@dataclass(frozen=True, kw_only=True) +class SwitchCandidate: + """One candidate of a switch-identifier resolution — a branch that may host the requested work. + + Attributes: + branch: The display name of the candidate branch. + topic: The topic slug the branch hosts, or ``None`` for a branch + without a topic. + statuses: The qualified names of the maximal present statuses, in + scale order — empty for a branch without a topic. + current: ``True`` for the current branch. + remote: ``True`` when the candidate ref is remote-tracking. + """ + + branch: str + topic: str | None + statuses: list[str] + current: bool + remote: bool + + +def resolve_switch_candidates(identifier: str, year: str | None = None) -> list[SwitchCandidate]: + """Resolve a switch identifier into its candidate branches. + + Args: + identifier: The user input — a branch name, a topic slug, or their + prefix. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The matching candidates, exact matches first, then prefix matches — + locals before remote-tracking refs, then by branch, then by topic. + Every branch appears once: a remote-tracking candidate whose local + twin hosts the same topic is collapsed — the local branch wins — + and a branch hosting several topics of the year contributes its + first entry only. A branch without a topic is a valid candidate + with ``topic=None`` and empty ``statuses``. + + Algorithm: + 1. Normalize ``identifier`` into a slug via ``normalize_topic_slug`` + 2. Collect the branch inventory and the topics of the resolved year + — the same ref-tree walk as the board, with the current branch + read from the working copy + 3. Exact branch name match -> the candidates hosting that name + 4. Exact slug match otherwise -> the branches hosting the slug, + local branches first + 5. Prefix matches otherwise -> the branches whose name or hosted + slug starts with the input + 6. Collapse the tier to one entry per branch and return the + candidates with their statuses + + Requirements: + Exact matches always precede prefix matches — the first non-empty + tier wins and excludes every other tier. + A branch appears in the result once — a local branch beats its + remote twin, so an unambiguous identifier never reaches a prompt. + A branch without a topic is a valid candidate. + Read-only — no mutation before a choice. + + Constraints: + Do not choose among multiple candidates — selection belongs to the + caller. + + Raises: + click.ClickException: a git infrastructure failure (its stderr when + git reports one, or a missing git binary), or the fatal + ``ImportError`` of the scale assembly — the broken tool package + is named in the message. + """ + try: + return _resolve_switch_candidates(identifier, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except ImportError as exc: + raise click.ClickException(str(exc)) from exc + + +def switch_topic(identifier: str, todo: bool = False, year: str | None = None) -> str: + """Bring the repository onto the branch hosting the requested work. + + With the todo flag, enter the todo of the switched topic after the switch. + + Args: + identifier: The user input — a branch name, a topic slug, or their + prefix. + todo: ``True`` enters the todo of the switched topic. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + One line describing the outcome — the idempotent success, the + checkout, or the branch creation. + + Algorithm: + 1. ``todo`` without an interactive terminal -> clean error before + any switching + 2. Resolve the candidates via ``resolve_switch_candidates``; none -> + clean error with a hint to the board; several -> print the + numbered list with statuses and prompt for a number, or fail + with the list when no interactive input is available + 3. ``todo`` and the chosen candidate hosts no topic -> clean error + — switching creates nothing + 4. Already on the hosting branch -> idempotent success, no mutation, + no cleanliness probe; with ``todo`` the entry still runs + 5. A mutation is needed -> probe the working tree cleanliness first + via ``is_working_tree_clean``; a dirty tree is a clean error + naming the reason and the next step — commit or stash the + working copy before switching + 6. Local host -> check out the branch via ``checkout_local_branch``; + remote-only host -> create the local branch from the + remote-tracking ref via ``create_branch_from_remote_tracking`` + 7. With ``todo`` -> enter the todo of the topic via + ``enter_topic_todo`` — after the switch + 8. Return the single result line + + Requirements: + Every mutation is local — no network, no fetch, no push. + Nothing is mutated before the candidate choice is complete. + The result is exactly one line. + + Constraints: + Do not create a topic for a branch without one. + Do not commit the todo write. + Do not manage the stages of the hosting pipeline — continuation + belongs to the pipeline itself. + Do not return to the previous branch — the switch is the outcome. + + Raises: + click.ClickException: ``todo`` without an interactive terminal, no + branch hosts the identifier, several candidates without an + interactive terminal, the chosen candidate hosts no topic under + ``todo``, a dirty working tree, a git infrastructure failure + (its stderr when git reports one, or a missing git binary), or + the fatal ``ImportError`` of the scale assembly. + click.Abort: Ctrl-C or EOF at the selection prompt — the repository + is left untouched. + """ + try: + return _switch_topic(identifier, todo, year) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or "").strip() or str(exc) + raise click.ClickException(f"git failed: {detail}") from exc + except FileNotFoundError as exc: + raise click.ClickException(f"git is not available: {exc}") from exc + except ImportError as exc: + raise click.ClickException(str(exc)) from exc + + +def _resolve_switch_candidates(identifier: str, year: str | None) -> list[SwitchCandidate]: + """Build the candidate inventory and take the first non-empty tier. + + Args: + identifier: The user input as entered. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The candidates of the first non-empty resolution tier — exact + branch, exact slug, prefix — collapsed to one entry per branch. + """ + resolved_year = year or current_year() + scale = assemble_status_scale() + inventory = list_branch_refs() + current = resolve_current_branch_name() + candidates = _hosted_candidates(inventory, current, resolved_year, scale) + + slug = normalize_topic_slug(identifier) + tiers = ( + [candidate for candidate in candidates if candidate.branch == identifier], + [candidate for candidate in candidates if candidate.topic == slug], + [ + candidate + for candidate in candidates + if candidate.branch.startswith(identifier) + or (slug != "" and candidate.topic is not None and candidate.topic.startswith(slug)) + ], + ) + + for tier in tiers: + if tier: + return _unique_candidates(tier) + + return [] + + +def _hosted_candidates( + refs: list[BranchRef], current: str | None, year: str, scale: StatusScale +) -> list[SwitchCandidate]: + """List every hosted-work candidate of the branch inventory. + + One candidate per ``(branch, hosted slug)`` pair — a branch hosting + several topics of the year yields several candidates, a branch hosting + none yields one ``topic=None`` candidate. The current branch is read + from the working copy exactly like the board row: the shared helper + guards the empty-slug branch name before the path oracles, which raise + on it before their existence check. + + Args: + refs: The full branch inventory. + current: The current branch name, or ``None`` when there is none. + year: The resolved year as four digits. + scale: The assembled status scale. + + Returns: + The candidates ordered local-first, then by branch, then by topic. + """ + topics_by_ref = _year_topics_by_ref(refs, year) + hosted: list[tuple[BranchRef, str | None, list[str]]] = [] + + for ref in refs: + if current is not None and not ref.remote and ref.name == current: + working_copy = _current_branch_topic(current, year, scale) + if working_copy is None: + hosted.append((ref, None, [])) + else: + slug, statuses, _todo = working_copy + hosted.append((ref, slug, statuses)) + continue + + topics = topics_by_ref[ref.name] + if not topics: + hosted.append((ref, None, [])) + continue + for slug, artifacts in topics.items(): + hosted.append((ref, slug, scale.maximal_present(artifacts))) + + hosted.sort(key=lambda entry: (entry[0].remote, entry[0].name, entry[1] or "")) + + return [ + SwitchCandidate( + branch=ref.name, + topic=slug, + statuses=statuses, + current=current is not None and not ref.remote and ref.name == current, + remote=ref.remote, + ) + for ref, slug, statuses in hosted + ] + + +def _unique_candidates(candidates: list[SwitchCandidate]) -> list[SwitchCandidate]: + """Collapse the redundant candidates of one resolution tier. + + A remote-tracking candidate whose local twin hosts the same topic is + dropped — the local branch wins, mirroring the board's twin collapse — + and every branch is kept once: the first entry of the tier order + (locals first, then branch, then topic) carries it, so a branch + hosting several topics of the year never repeats in the list and an + unambiguous identifier stays unambiguous. + + Args: + candidates: The candidates of one resolution tier, in tier order. + + Returns: + The candidates without remote twins and branch repetitions. + """ + local_topics = {(candidate.topic, candidate.branch) for candidate in candidates if not candidate.remote} + unique: list[SwitchCandidate] = [] + branches: set[str] = set() + + for candidate in candidates: + hosted_twin = (candidate.topic, _short_name(candidate.branch)) in local_topics + if candidate.remote and hosted_twin: + continue + if candidate.branch in branches: + continue + branches.add(candidate.branch) + unique.append(candidate) + + return unique + + +def _switch_topic(identifier: str, todo: bool, year: str | None) -> str: + """Run the traced switch procedure — the unwrapped orchestration. + + Args: + identifier: The user input as entered. + todo: ``True`` enters the todo of the switched topic after the switch. + year: Optional year as four digits; ``None`` means the current year. + + Returns: + The single result line of the outcome. + """ + if todo and not sys.stdin.isatty(): + raise click.ClickException("the todo entry needs an interactive terminal") + + candidates = resolve_switch_candidates(identifier, year) + + if not candidates: + raise click.ClickException(f"no branch hosts {identifier!r} — run 'goga topics board' to see the board") + + chosen = _take_candidate(candidates) + + if todo and chosen.topic is None: + raise click.ClickException(f"branch '{chosen.branch}' hosts no topic — switching creates nothing") + + line = _apply_candidate(chosen) + + if todo: + enter_topic_todo(chosen.topic, year) + + return line + + +def _take_candidate(candidates: list[SwitchCandidate]) -> SwitchCandidate: + """Narrow the resolved candidates to the chosen one. + + Args: + candidates: The non-empty candidate list of the resolution. + + Returns: + The single candidate — taken directly when unambiguous, chosen by + the numbered selection otherwise. + """ + return candidates[0] if len(candidates) == 1 else _choose_candidate(candidates) + + +def _apply_candidate(chosen: SwitchCandidate) -> str: + """Bring the working copy onto the chosen candidate — the mutation tail + of ``switch_topic``. + + Args: + chosen: The chosen candidate of the resolution. + + Returns: + The single result line of the outcome. + + Raises: + click.ClickException: a dirty working tree when a mutation is + needed. + """ + if chosen.current: + return f"Already on branch {chosen.branch}" + if not is_working_tree_clean(): + raise click.ClickException("working tree is dirty — commit or stash before switching") + if not chosen.remote: + checkout_local_branch(chosen.branch) + return f"Switched to branch {chosen.branch}" + + create_branch_from_remote_tracking(BranchRef(name=chosen.branch, remote=True)) + short = chosen.branch.partition("/")[2] + + return f"Created branch {short} from {chosen.branch}" + + +def _choose_candidate(candidates: list[SwitchCandidate]) -> SwitchCandidate: + """Narrow several candidates to one — the numbered selection. + + Args: + candidates: The candidate list of the resolution — two or more. + + Returns: + The chosen candidate. + + Raises: + click.ClickException: without a terminal — the numbered list goes to + the user as a non-interactive abort. + click.Abort: Ctrl-C or EOF at the prompt. + """ + lines = _numbered_lines(candidates) + + if not sys.stdin.isatty(): + raise click.ClickException("\n".join(lines)) + + for line in lines: + click.echo(line) + + number = click.prompt("Select a branch by number", type=click.IntRange(1, len(candidates))) + + return candidates[number - 1] + + +def _numbered_lines(candidates: list[SwitchCandidate]) -> list[str]: + """Render the numbered candidate list with the status segments. + + Args: + candidates: The candidate list of the resolution. + + Returns: + One line per candidate — ``N) <branch> (<topic>) [status] ...`` with + the topic and the status segments present only when hosted. + """ + lines = [] + + for index, candidate in enumerate(candidates, start=1): + line = f"{index}) {candidate.branch}" + if candidate.topic is not None: + line += f" ({candidate.topic})" + if candidate.statuses: + line += " " + " ".join(f"[{status}]" for status in candidate.statuses) + + lines.append(line) + + return lines diff --git a/mkdocs.yml b/mkdocs.yml index 961d7ba6..927445a4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,7 +4,7 @@ site_name: Goga site_description: A tool for working with the codemanifest specification — assembly, extension, and plan-building workflow site_url: https://qarium.github.io/goga/ repo_url: https://github.com/qarium/goga -edit_uri: edit/1.1.x/docs/ +edit_uri: edit/1.3.x/docs/ theme: name: material @@ -50,36 +50,108 @@ nav: - Build: workflow/build.md - Change: workflow/change.md - Accept: workflow/accept.md - - Pipelines: - - pipelines/index.md - - Pipeline File: pipelines/pipeline-file.md - - Workflows: pipelines/workflows.md - - Shipped Pipelines: pipelines/shipped.md - - Tools: tools.md + - Features: + - features/index.md + - Topics: + - Overview: features/topics/index.md + - CLI: features/topics/cli.md + - Configuration: features/topics/configuration.md + - Hooks: features/topics/hooks.md + - API: features/topics/api.md + - History: + - Overview: features/history/index.md + - CLI: features/history/cli.md + - Configuration: features/history/configuration.md + - Hooks: features/history/hooks.md + - API: features/history/api.md + - Pipelines: + - Overview: features/pipelines/index.md + - CLI: features/pipelines/cli.md + - Configuration: features/pipelines/configuration.md + - Hooks: features/pipelines/hooks.md + - API: features/pipelines/api.md + - Pipeline File: features/pipelines/pipeline-file.md + - Workflows: features/pipelines/workflows.md + - Shipped Pipelines: features/pipelines/shipped.md + - Build: + - Overview: features/build/index.md + - CLI: features/build/cli.md + - Configuration: features/build/configuration.md + - Hooks: features/build/hooks.md + - API: features/build/api.md + - Tools: + - Overview: features/tools/index.md + - CLI: features/tools/cli.md + - Configuration: features/tools/configuration.md + - Hooks: features/tools/hooks.md + - API: features/tools/api.md + - Connect: + - Overview: features/connect/index.md + - CLI: features/connect/cli.md + - Configuration: features/connect/configuration.md + - Hooks: features/connect/hooks.md + - API: features/connect/api.md + - Upgrade: + - Overview: features/upgrade/index.md + - CLI: features/upgrade/cli.md + - Configuration: features/upgrade/configuration.md + - Hooks: features/upgrade/hooks.md + - API: features/upgrade/api.md + - Install: + - Overview: features/install/index.md + - CLI: features/install/cli.md + - Configuration: features/install/configuration.md + - Hooks: features/install/hooks.md + - API: features/install/api.md + - Uninstall: features/install/uninstall.md + - Init: + - Overview: features/init/index.md + - CLI: features/init/cli.md + - Configuration: features/init/configuration.md + - Hooks: features/init/hooks.md + - API: features/init/api.md + - Usages: + - Overview: features/usages/index.md + - CLI: features/usages/cli.md + - Configuration: features/usages/configuration.md + - Hooks: features/usages/hooks.md + - API: features/usages/api.md + - Schema: + - Overview: features/schema/index.md + - CLI: features/schema/cli.md + - Configuration: features/schema/configuration.md + - Hooks: features/schema/hooks.md + - API: features/schema/api.md + - Contract: + - Overview: features/contract/index.md + - CLI: features/contract/cli.md + - Configuration: features/contract/configuration.md + - Hooks: features/contract/hooks.md + - API: features/contract/api.md + - Hooks: + - Overview: features/hooks/index.md + - CLI: features/hooks/cli.md + - Configuration: features/hooks/configuration.md + - Hooks: features/hooks/hooks.md + - API: features/hooks/api.md + - Lint: + - Overview: features/lint/index.md + - CLI: features/lint/cli.md + - Configuration: features/lint/configuration.md + - Hooks: features/lint/hooks.md + - API: features/lint/api.md + - Errors: features/lint/errors.md - Configuration: - configuration/index.md - Project: configuration/project.md - Home: configuration/home.md - Agents: configuration/agents.md + - CLI: configuration/cli.md - Cell: - cell/index.md - CODEMANIFEST: cell/codemanifest.md - Usages: cell/usages.md - - CLI: - - cli/index.md - - Init: cli/init.md - - Install: cli/install.md - - Uninstall: cli/uninstall.md - - Lint: cli/lint.md - - Build: cli/build.md - - Contract: cli/contract.md - - Config: cli/config.md - - Schema: cli/schema.md - - Connect: cli/connect.md - - Upgrade: cli/upgrade.md - - Usages: cli/usages.md - - Pipeline: cli/pipeline.md - - Tool: cli/tool.md + - CLI: cli/index.md - Architecture: - architecture/index.md - AST Nodes: architecture/ast-nodes.md diff --git a/tests/build/test_build.py b/tests/build/test_build.py index 4a76ca2b..8cd769e6 100644 --- a/tests/build/test_build.py +++ b/tests/build/test_build.py @@ -169,7 +169,16 @@ def test_resolve_options_omits_when_config_none(self) -> None: assert resolved["idle_timeout"] is None assert resolved["wait"] is None assert resolved["max_iterations"] is None - assert resolved["review_patience"] is None + assert "review_patience" not in resolved + + def test_resolve_options_universal_zone_drops_review_patience(self) -> None: + # Two-zone contract: the universal resolver owns worktree/skip_finalize/ + # session_timeout/idle_timeout/wait/max_iterations only — the + # review-scoped keys (review_patience, base_ref) are resolved by + # resolve_review_options, never here, even when the CLI carries them. + resolved = _resolve_options(_make_config(), {"review_patience": 5, "base_ref": "x"}) + assert "review_patience" not in resolved + assert "base_ref" not in resolved def test_resolve_options_skip_finalize_config_value_when_cli_absent(self) -> None: # Mirror of the worktree case for skip_finalize (the second bool key): @@ -1168,6 +1177,142 @@ def test_build_skip_run_skips_validation(self, tmp_path, monkeypatch) -> None: assert mock_run.call_args.args[1]["tasks_only"] is True +class TestReviewScopedPassComposition: + """Contract: review-scoped options (base_ref, review_patience) join the + options of review-carrying passes ONLY — the full-mode single pass and the + two-pass review pass. A skip run and the tasks-only pass carry universal + options only. Key-presence per pass is the API surface under contract.""" + + def test_full_pass_carries_review_scoped_options(self, tmp_path, monkeypatch) -> None: + # Same agent as the task executor and an empty review env -> a single + # full pass, which IS review-carrying: the scoped options ride along. + config = _make_config(review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/1.2.x", patience=3)) + + with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=config, + cli_options={"skip_manifest_check": True}, + ) + + assert result == 0 + assert mock_run.call_count == 1 + assert mock_run.call_args.args[1]["base_ref"] == "origin/1.2.x" + assert mock_run.call_args.args[1]["review_patience"] == 3 + + def test_two_pass_review_scoped_options_only_on_review_pass(self, tmp_path, monkeypatch) -> None: + # A differing review agent induces the two-pass mode: pass 1 is + # tasks-only (universal options only), pass 2 is the review pass and + # carries the scoped options. + config = _make_config(review_executor=ReviewExecutorConfig(agent="codex", base_ref="origin/1.2.x", patience=3)) + review_wrapper = tmp_path / "codex-as-claude.sh" + review_wrapper.write_text("#!/bin/sh\n") + + with ( + mock.patch("goga.build.review_config.resolve_wrapper_path", return_value=str(review_wrapper)), + mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run, + ): + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=config, + cli_options={"skip_manifest_check": True}, + ) + + assert result == 0 + assert mock_run.call_count == 2 + first = mock_run.call_args_list[0].args[1] + assert "base_ref" not in first + assert "review_patience" not in first + second = mock_run.call_args_list[1].args[1] + assert second["base_ref"] == "origin/1.2.x" + assert second["review_patience"] == 3 + assert second["review"] is True + + def test_cli_scoped_options_override_config_on_review_pass(self, tmp_path, monkeypatch) -> None: + # The CLI source flows through the same composition: cli_options carry + # base_ref/review_patience, the config declares different values, and + # the CLI wins on the review-carrying (here: single full) pass. + config = _make_config(review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/main", patience=3)) + + with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=config, + cli_options={"skip_manifest_check": True, "base_ref": "origin/1.2.x", "review_patience": 7}, + ) + + assert result == 0 + assert mock_run.call_count == 1 + assert mock_run.call_args.args[1]["base_ref"] == "origin/1.2.x" + assert mock_run.call_args.args[1]["review_patience"] == 7 + + def test_cli_scoped_options_without_review_executor_section(self, tmp_path, monkeypatch) -> None: + # A minimal config with no review_executor section still honors + # CLI-sourced review bounds on the single full pass — the resolver + # must read the CLI source without gating it on the section. + with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=_make_config(), + cli_options={"skip_manifest_check": True, "base_ref": "origin/1.2.x", "review_patience": 4}, + ) + + assert result == 0 + assert mock_run.call_count == 1 + assert mock_run.call_args.args[1]["base_ref"] == "origin/1.2.x" + assert mock_run.call_args.args[1]["review_patience"] == 4 + + def test_skip_run_omits_review_scoped_options(self, tmp_path, monkeypatch) -> None: + # A skip run has no review phase of any kind: even with review bounds + # declared, the single tasks-only pass carries universal options only. + config = _make_config(review_executor=ReviewExecutorConfig(skip=True, base_ref="origin/1.2.x", patience=3)) + + with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=config, + cli_options={"skip_manifest_check": True}, + ) + + assert result == 0 + assert mock_run.call_count == 1 + assert "base_ref" not in mock_run.call_args.args[1] + assert "review_patience" not in mock_run.call_args.args[1] + assert mock_run.call_args.args[1]["tasks_only"] is True + + def test_no_source_review_scoped_keys_absent_command_byte_identical(self, tmp_path, monkeypatch) -> None: + # Backward-compat criterion: with neither a review_executor section nor + # scoped CLI options, the keys stay absent from the captured options and + # the assembled ralphex command is byte-identical to the pre-change + # behavior — the bare prefix plus only the universal flags the fixture + # actually sets (here: none). + from goga.ralphex.run_ralphex import _build_command + + with mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run: + result = _run_build_in_tmp( + tmp_path, + monkeypatch, + config=_make_config(), + cli_options={"skip_manifest_check": True}, + ) + + assert result == 0 + captured_options = mock_run.call_args.args[1] + assert "base_ref" not in captured_options + assert "review_patience" not in captured_options + assert _build_command("plan.md", captured_options) == [ + "ralphex", + "plan.md", + "--config-dir", + ".ralphex/", + ] + + # --- Integration: secret-safe dry-run across the orchestration/launcher seam --- diff --git a/tests/build/test_main.py b/tests/build/test_main.py index 9b5c0209..f16c8859 100644 --- a/tests/build/test_main.py +++ b/tests/build/test_main.py @@ -121,6 +121,39 @@ def test_main_review_patience_flag(self, mock_build, mock_config, tmp_path, monk cli_options = mock_build.call_args[0][2] assert cli_options["review_patience"] == 5 + @mock.patch("goga.build.__main__.load_project_config") + @mock.patch("goga.build.__main__.build", return_value=0) + def test_main_base_ref_flag(self, mock_build, mock_config, tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _write_goga_yml(tmp_path) + + with ( + mock.patch.dict(os.environ, {"GOGA_DOCKER": "1"}), + mock.patch("sys.argv", ["goga.build", "plan.md", "--base-ref", "origin/1.2.x", "--skip-manifest-check"]), + ): + main() + + cli_options = mock_build.call_args[0][2] + assert cli_options["base_ref"] == "origin/1.2.x" + + @mock.patch("goga.build.__main__.load_project_config") + @mock.patch("goga.build.__main__.build", return_value=0) + def test_main_base_ref_absent_defaults_none(self, mock_build, mock_config, tmp_path, monkeypatch) -> None: + # Key present, value None — the tri-state survives to the resolver, + # which then falls through to build.review_executor.base_ref. + monkeypatch.chdir(tmp_path) + _write_goga_yml(tmp_path) + + with ( + mock.patch.dict(os.environ, {"GOGA_DOCKER": "1"}), + mock.patch("sys.argv", ["goga.build", "plan.md", "--skip-manifest-check"]), + ): + main() + + cli_options = mock_build.call_args[0][2] + assert "base_ref" in cli_options + assert cli_options["base_ref"] is None + @mock.patch("goga.build.__main__.load_project_config") @mock.patch("goga.build.__main__.build", return_value=0) def test_main_idle_timeout_flag(self, mock_build, mock_config, tmp_path, monkeypatch) -> None: diff --git a/tests/build/test_review_options.py b/tests/build/test_review_options.py index 2c3d5fdf..ea2d1629 100644 --- a/tests/build/test_review_options.py +++ b/tests/build/test_review_options.py @@ -38,7 +38,7 @@ def test_resolve_review_options_returns_review_options(self) -> None: def test_review_options_declared_fields(self) -> None: fields = {f.name for f in dataclasses.fields(ReviewOptions)} - assert fields == {"skip", "review_agent", "roles", "two_pass", "review_env"} + assert fields == {"skip", "review_agent", "roles", "two_pass", "review_env", "base_ref", "patience"} def test_review_options_field_types(self) -> None: hints = typing.get_type_hints(ReviewOptions) @@ -47,11 +47,13 @@ def test_review_options_field_types(self) -> None: assert hints["roles"] == list[str] | None assert hints["two_pass"] is bool assert hints["review_env"] == dict[str, str] + assert hints["base_ref"] == str | None + assert hints["patience"] == int | None def test_review_options_declared_fields_include_review_env(self) -> None: """`review_env` is the fifth field, required — no default factory.""" names = [f.name for f in dataclasses.fields(ReviewOptions)] - assert names == ["skip", "review_agent", "roles", "two_pass", "review_env"] + assert names == ["skip", "review_agent", "roles", "two_pass", "review_env", "base_ref", "patience"] env_field = next(f for f in dataclasses.fields(ReviewOptions) if f.name == "review_env") assert env_field.default is dataclasses.MISSING assert env_field.default_factory is dataclasses.MISSING @@ -62,6 +64,17 @@ def test_review_options_is_kw_only_and_frozen(self) -> None: with pytest.raises(TypeError): ReviewOptions(False, None, None, False, {}) # type: ignore[misc] + def test_review_options_declares_base_ref_and_patience_fields(self) -> None: + assert {"base_ref", "patience"} <= set(ReviewOptions.__dataclass_fields__) + + def test_resolve_review_options_docstring_lists_three_keys(self) -> None: + """The docstring names the three cli_options keys; the old one-key wording is gone.""" + doc = resolve_review_options.__doc__ or "" + + for key in ("skip_review", "base_ref", "review_patience"): + assert key in doc + assert "only `skip_review` is read" not in doc + class TestResolveReviewOptionsLogic: @pytest.mark.parametrize("cli", [None, True, False]) @@ -185,3 +198,78 @@ def test_resolve_review_options_env_without_agent_single_pass(self) -> None: assert result.two_pass is False assert result.review_agent is None assert result.review_env == {"X": "y"} + + def test_resolve_review_options_base_ref_cli_overrides_config(self) -> None: + config = _make_build_config( + review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/main"), + ) + + result = resolve_review_options(config, {"base_ref": "origin/1.2.x"}) + + assert result.base_ref == "origin/1.2.x" + + def test_resolve_review_options_base_ref_from_config_when_cli_absent(self) -> None: + config = _make_build_config( + review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/1.2.x"), + ) + + result = resolve_review_options(config, {}) + + assert result.base_ref == "origin/1.2.x" + + def test_resolve_review_options_patience_cli_overrides_config(self) -> None: + """Pins the naming split: the cli_options KEY is review_patience, + the ReviewOptions FIELD is patience.""" + config = _make_build_config( + review_executor=ReviewExecutorConfig(agent="claude", patience=3), + ) + + result = resolve_review_options(config, {"review_patience": 7}) + + assert result.patience == 7 + + def test_resolve_review_options_base_ref_empty_whitespace_resolves_unset(self) -> None: + config = _make_build_config( + review_executor=ReviewExecutorConfig(agent="claude", base_ref=" "), + ) + + result = resolve_review_options(config, {}) + + assert result.base_ref is None + + @pytest.mark.parametrize( + ("config_base_ref", "cli_options"), + [ + (" origin/1.2.x ", {}), + (None, {"base_ref": " origin/1.2.x "}), + ], + ids=["config-source", "cli-source"], + ) + def test_resolve_review_options_base_ref_padded_value_stripped(self, config_base_ref, cli_options) -> None: + """Exact equality — an implementation that only checks emptiness + without assigning the stripped value fails.""" + config = _make_build_config( + review_executor=ReviewExecutorConfig(agent="claude", base_ref=config_base_ref), + ) + + result = resolve_review_options(config, cli_options) + + assert result.base_ref == "origin/1.2.x" + + def test_resolve_review_options_base_ref_empty_cli_means_unset_not_fallback(self) -> None: + """An explicitly empty CLI value does NOT fall through to the config.""" + config = _make_build_config( + review_executor=ReviewExecutorConfig(agent="claude", base_ref="origin/main"), + ) + + result = resolve_review_options(config, {"base_ref": ""}) + + assert result.base_ref is None + + def test_resolve_review_options_no_review_executor_leaves_scoped_none(self) -> None: + config = _make_build_config(review_executor=None) + + result = resolve_review_options(config, {}) + + assert result.base_ref is None + assert result.patience is None diff --git a/tests/commands/build/test_build_proxy_hosts_update.py b/tests/commands/build/test_build_proxy_hosts_update.py index 18b0060c..41dc6706 100644 --- a/tests/commands/build/test_build_proxy_hosts_update.py +++ b/tests/commands/build/test_build_proxy_hosts_update.py @@ -86,9 +86,9 @@ def test_build_update_has_short_flag(self) -> None: update_param = next(p for p in build_cmd.params if p.name == "update") assert "-u" in update_param.opts - def test_build_fifteen_options(self) -> None: + def test_build_sixteen_options(self) -> None: options = [p for p in build_cmd.params if isinstance(p, click.Option)] - assert len(options) == 15 + assert len(options) == 16 def test_help_lists_new_options(self) -> None: runner = CliRunner() diff --git a/tests/commands/conftest.py b/tests/commands/conftest.py index 2557a486..4f4e5519 100644 --- a/tests/commands/conftest.py +++ b/tests/commands/conftest.py @@ -36,7 +36,9 @@ def full_config(tmp_path: Path) -> Path: " idle_timeout: '1h'\n" " wait: '5m'\n" " max_iterations: 10\n" - " review_patience: 3\n" + " review_executor:\n" + " base_ref: origin/1.2.x\n" + " patience: 3\n" " prompts_dir: /custom/prompts\n" " agents_dir: /custom/agents\n" " codex_review: true\n" diff --git a/tests/commands/history/__init__.py b/tests/commands/history/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/commands/history/test_history.py b/tests/commands/history/test_history.py new file mode 100644 index 00000000..120f9f28 --- /dev/null +++ b/tests/commands/history/test_history.py @@ -0,0 +1,299 @@ +"""Contract and logic tests for the entity declared in +``goga/commands/history/CODEMANIFEST`` with ``location: history.py``: +the ``history`` click group with the ``list``/``status``/``path``/``ensure``/ +``prune`` subcommands. + +The group is a thin wrapper: it carries the shared ``-y/--year`` option, +inputs are resolved here, every computation is delegated to the +``goga.history`` domain, and output goes through the ``render`` module. +The logic tests cover the negative paths — domain errors (``ValueError``), +an undetermined git branch, and validation failures must surface as +``click.ClickException`` (stderr, exit 1, no traceback), while the removed +year forms (a positional YEAR, a year option after the subcommand) are +click's own usage errors (exit 2). The positive cross-entity scenarios +live in ``tests/integration/test_history_command.py``. +""" + +from __future__ import annotations + +import inspect +import sys +import typing +from unittest import mock + +import click +import pytest +from click.testing import CliRunner +from goga.commands.history import history, render_history_tree, render_topic_statuses +from goga.history import prune_topics + +# goga.commands.history.history is shadowed in the package __init__ by the +# history click group, so attribute access through the package gives the +# group. Resolve the real module via sys.modules (precedent: test_pipeline). +_history_module = sys.modules["goga.commands.history.history"] +# The facade __all__ lives on the cell package itself. +_history_facade = sys.modules["goga.commands.history"] + +# --- Contract tests --- + + +class TestHistoryGroupContract: + def test_history_importable_from_facade(self) -> None: + """history is importable from the goga.commands.history facade.""" + assert _history_module.history is history + + def test_facade_exports_three_names(self) -> None: + """The cell facade carries the three declared names, alphabetically.""" + assert _history_facade.__all__ == ["history", "render_history_tree", "render_topic_statuses"] + assert callable(history) + assert callable(render_history_tree) + assert callable(render_topic_statuses) + + def test_history_is_a_click_group(self) -> None: + """history is a click.Group container for the subcommands.""" + assert isinstance(history, click.Group) + + def test_history_registers_five_subcommands(self) -> None: + """The group carries exactly the five declared subcommands.""" + assert sorted(history.commands) == ["ensure", "list", "path", "prune", "status"] + + def test_history_module_binds_domain_prune_topics(self) -> None: + """The command module imports the domain cleanup routine at its site.""" + assert _history_module.prune_topics is prune_topics + + def test_history_group_carries_only_the_year_option(self) -> None: + """The group owns the shared -y/--year option — and nothing else.""" + assert len(history.params) == 1 + year_option = history.params[0] + assert isinstance(year_option, click.Option) + assert year_option.name == "year" + assert {"-y", "--year"} <= set(year_option.opts) + assert year_option.default is None + + def test_history_group_callback_signature(self) -> None: + """``history(ctx, year)`` — the context and the scoped year.""" + callback = history.callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["ctx", "year"] + assert signature.parameters["year"].default is None + + def test_history_scope_is_a_kw_only_dataclass_with_year(self) -> None: + """``_HistoryScope`` is a kw_only dataclass carrying the year field.""" + scope = _history_module._HistoryScope(year="2025") + assert scope.year == "2025" + assert _history_module._HistoryScope().year is None + # Not frozen — the group assigns the year after ensure_object. + scope.year = None + assert scope.year is None + + def test_list_topics_does_not_shadow_builtin_list(self) -> None: + """The list subcommand callback is named list_topics, not list.""" + assert callable(_history_module.list_topics) + assert not hasattr(_history_module, "list") + + def test_list_callback_signature(self) -> None: + """``list_topics(scope)`` — the scope object alone.""" + callback = history.commands["list"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["scope"] + hints = typing.get_type_hints(callback) + assert hints == { + "scope": _history_module._HistoryScope, + "return": type(None), + } + + def test_status_callback_signature(self) -> None: + """``status(scope, topic, statuses)`` with the tuple default ``()``.""" + callback = history.commands["status"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["scope", "topic", "statuses"] + assert signature.parameters["statuses"].default == () + hints = typing.get_type_hints(callback) + assert hints == { + "scope": _history_module._HistoryScope, + "topic": str | None, + "statuses": tuple[str, ...], + "return": type(None), + } + + def test_path_callback_signature(self) -> None: + """``path(scope, topic, filename)``.""" + callback = history.commands["path"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["scope", "topic", "filename"] + hints = typing.get_type_hints(callback) + assert hints == { + "scope": _history_module._HistoryScope, + "topic": str | None, + "filename": str | None, + "return": type(None), + } + + def test_ensure_callback_signature(self) -> None: + """``ensure(scope, name)``.""" + callback = history.commands["ensure"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["scope", "name"] + hints = typing.get_type_hints(callback) + assert hints == { + "scope": _history_module._HistoryScope, + "name": str | None, + "return": type(None), + } + + def test_prune_callback_signature(self) -> None: + """``prune(scope, dry_run)`` with the declared default ``False``.""" + callback = history.commands["prune"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["scope", "dry_run"] + assert signature.parameters["dry_run"].default is False + hints = typing.get_type_hints(callback) + assert hints == { + "scope": _history_module._HistoryScope, + "dry_run": bool, + "return": type(None), + } + + def test_status_options(self) -> None: + """status: -t/--topic and repeatable -s/--status — no year surface.""" + command = history.commands["status"] + assert all(param.name != "year" for param in command.params) + topic_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "topic") + assert "-t" in topic_option.opts + assert "--topic" in topic_option.opts + status_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "statuses") + assert "-s" in status_option.opts + assert "--status" in status_option.opts + assert status_option.multiple is True + + def test_path_options(self) -> None: + """path: optional TOPIC positional and -f/--file — no year option.""" + command = history.commands["path"] + assert all(param.name != "year" for param in command.params) + topic_argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "topic") + assert topic_argument.required is False + file_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "filename") + assert "-f" in file_option.opts + assert "--file" in file_option.opts + + def test_ensure_argument(self) -> None: + """ensure: optional NAME positional.""" + command = history.commands["ensure"] + name_argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "name") + assert name_argument.required is False + + def test_prune_options(self) -> None: + """prune: the --dry-run flag alone — no arguments, no year surface.""" + command = history.commands["prune"] + assert len(command.params) == 1 + dry_run_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "dry_run") + assert "--dry-run" in dry_run_option.opts + assert dry_run_option.is_flag is True + assert dry_run_option.default is False + + +# --- Logic tests (negative paths, via CliRunner) --- + + +class TestHistoryNegativePaths: + def test_history_status_unknown_status_name(self) -> None: + """An unknown -s name is a clean error — no traceback, exit 1.""" + result = CliRunner().invoke(history, ["status", "-s", "bogus"]) + assert result.exit_code == 1 + assert "unknown status name" in result.stderr + assert "Traceback" not in result.stderr + + def test_history_status_broken_scale_assembly_fails_cleanly(self) -> None: + """A fatal scale assembly error (broken goga_tool_* import) surfaces clean.""" + runner = CliRunner() + + with mock.patch.object( + _history_module, + "assemble_status_scale", + side_effect=ImportError("package goga_tool_bad failed to import: boom"), + ): + result = runner.invoke(history, ["status"]) + assert result.exit_code == 1 + assert "goga_tool_bad" in result.stderr + assert "Traceback" not in result.stderr + + def test_history_status_empty_topic_filter_is_error(self) -> None: + """A -t value normalizing to an empty slug is an error, not match-all.""" + result = CliRunner().invoke(history, ["status", "-t", "Релиз"]) + assert result.exit_code == 1 + assert "empty topic slug" in result.stderr + assert result.stdout == "" + + @pytest.mark.parametrize( + ("argv", "stderr_fragment"), + [ + (["-y", "2026", "status", "-t", "Релиз"], "empty topic slug"), + (["path", "Релиз/Один", "-f", "plan.md"], "empty topic slug"), + (["ensure", "Релиз/Один"], "empty topic slug"), + ], + ) + def test_history_empty_slug_inputs_are_clean_errors(self, argv: list[str], stderr_fragment: str) -> None: + """Every subcommand converts the domain empty-slug error to exit 1.""" + result = CliRunner().invoke(history, argv) + assert result.exit_code == 1 + assert stderr_fragment in result.stderr + assert "Traceback" not in result.stderr + + def test_history_path_no_branch_fails_cleanly(self) -> None: + """path without a positional and without a determinable branch fails clean.""" + runner = CliRunner() + + with mock.patch.object(_history_module, "resolve_current_branch_name", return_value=None): + result = runner.invoke(history, ["path"]) + assert result.exit_code == 1 + assert "cannot determine the current git branch" in result.stderr + assert result.stdout == "" + assert "Traceback" not in result.stderr + + def test_history_path_extensionless_file_fails(self) -> None: + """An extensionless -f value surfaces the domain error as a clean error.""" + result = CliRunner().invoke(history, ["path", "feat-x", "-f", "noext"]) + assert result.exit_code == 1 + assert "must carry an extension" in result.stderr + + def test_history_ensure_no_branch_fails_cleanly(self) -> None: + """ensure without a positional and without a determinable branch fails clean.""" + runner = CliRunner() + + with mock.patch.object(_history_module, "resolve_current_branch_name", return_value=None): + result = runner.invoke(history, ["ensure"]) + assert result.exit_code == 1 + assert "cannot determine the current git branch" in result.stderr + assert result.stdout == "" + + +class TestHistoryYearUsageErrors: + def test_history_status_positional_year_is_usage_error(self) -> None: + """status 2025 — the removed positional YEAR is click's usage error.""" + result = CliRunner().invoke(history, ["status", "2025"]) + assert result.exit_code == 2 + assert "Usage" in result.stderr + assert "extra argument" in result.stderr + assert "Traceback" not in result.stderr + + def test_history_prune_positional_year_is_usage_error(self) -> None: + """prune 2025 — the removed positional YEAR is click's usage error.""" + result = CliRunner().invoke(history, ["prune", "2025"]) + assert result.exit_code == 2 + assert "Usage" in result.stderr + assert "extra argument" in result.stderr + assert "Traceback" not in result.stderr + + def test_history_path_year_option_is_usage_error(self) -> None: + """path -y 2025 — the removed local year option is click's usage error.""" + result = CliRunner().invoke(history, ["path", "feat-x", "-y", "2025"]) + assert result.exit_code == 2 + assert "No such option" in result.stderr + assert "Traceback" not in result.stderr + + def test_history_status_year_option_after_subcommand_is_usage_error(self) -> None: + """status -y 2025 — the year option belongs to the group, before the subcommand.""" + result = CliRunner().invoke(history, ["-y", "2026", "status", "-y", "2025"]) + assert result.exit_code == 2 + assert "No such option" in result.stderr + assert "Traceback" not in result.stderr diff --git a/tests/commands/history/test_render.py b/tests/commands/history/test_render.py new file mode 100644 index 00000000..c7bf3410 --- /dev/null +++ b/tests/commands/history/test_render.py @@ -0,0 +1,137 @@ +"""Contract and logic tests for the entities declared in +``goga/commands/history/CODEMANIFEST`` with ``location: render.py``: + +- ``render_history_tree(tree: list[HistoryYear])`` +- ``render_topic_statuses(records: list[TopicRecord])`` + +The renderers are pure output: what the input carries is printed as given — +no sorting, no filtering, no computation, and the year of a status record is +never printed. Output is captured with ``capsys``; the color test mocks +``click.secho`` at the import point of the render module. +""" + +from __future__ import annotations + +import inspect +import typing +from unittest import mock + +import pytest +from goga.commands.history import render +from goga.commands.history.render import render_history_tree, render_topic_statuses +from goga.history import HistoryYear, TopicRecord + +# --- Contract tests --- + + +class TestRenderContract: + def test_entities_are_importable_from_module_and_callable(self) -> None: + """Both renderers are importable from ``goga.commands.history.render``.""" + assert render.render_history_tree is render_history_tree + assert render.render_topic_statuses is render_topic_statuses + assert callable(render_history_tree) + assert callable(render_topic_statuses) + + def test_render_history_tree_signature(self) -> None: + """``render_history_tree(tree: list[HistoryYear]) -> None``.""" + signature = inspect.signature(render_history_tree) + assert list(signature.parameters) == ["tree"] + assert signature.parameters["tree"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + hints = typing.get_type_hints(render_history_tree) + assert hints == {"tree": list[HistoryYear], "return": type(None)} + + def test_render_topic_statuses_signature(self) -> None: + """``render_topic_statuses(records: list[TopicRecord]) -> None``.""" + signature = inspect.signature(render_topic_statuses) + assert list(signature.parameters) == ["records"] + assert signature.parameters["records"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + hints = typing.get_type_hints(render_topic_statuses) + assert hints == {"records": list[TopicRecord], "return": type(None)} + + +# --- Logic tests --- + + +class TestRenderHistoryTree: + def test_render_history_tree_lines(self, capsys: pytest.CaptureFixture[str]) -> None: + """Each year prints ``YYYY/`` and each topic an indented marker line under it.""" + tree = [ + HistoryYear(year="2025", topics=["a-topic", "b-topic"]), + HistoryYear(year="2026", topics=["history-commands"]), + ] + render_history_tree(tree) + assert capsys.readouterr().out == "2025/\n └── a-topic\n └── b-topic\n2026/\n └── history-commands\n" + + def test_render_history_tree_empty_input_prints_nothing(self, capsys: pytest.CaptureFixture[str]) -> None: + """An empty tree renders not a single line.""" + render_history_tree([]) + assert capsys.readouterr().out == "" + + def test_render_history_tree_does_not_mutate_input(self, capsys: pytest.CaptureFixture[str]) -> None: + """Topics print in the given order — the renderer does not re-sort or mutate.""" + tree = [HistoryYear(year="2025", topics=["z-topic", "a-topic"])] + render_history_tree(tree) + capsys.readouterr() + assert tree[0].topics == ["z-topic", "a-topic"] + + +class TestRenderTopicStatuses: + def test_render_topic_statuses_no_color_plain( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """A non-empty NO_COLOR keeps every segment plain — no ANSI escapes.""" + monkeypatch.setenv("NO_COLOR", "1") + render_topic_statuses([TopicRecord(topic="t", statuses=["planned"])]) + captured = capsys.readouterr() + assert captured.out.strip() == "t [planned]" + assert "\x1b" not in captured.out + + def test_render_topic_statuses_one_segment_per_status( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """Every status of a record prints as its own space-separated segment.""" + monkeypatch.setenv("NO_COLOR", "1") + render_topic_statuses([TopicRecord(topic="release-1-3-0", statuses=["done", "mkdocs.published"])]) + captured = capsys.readouterr() + assert captured.out == "release-1-3-0 [done] [mkdocs.published]\n" + assert "\x1b" not in captured.out + + def test_render_topic_statuses_colors_status_segment( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """One color on the status segments; the topic stays plain with no newline.""" + monkeypatch.delenv("NO_COLOR", raising=False) + + with mock.patch.object(render.click, "secho") as secho_mock: + render_topic_statuses([TopicRecord(topic="t", statuses=["planned"])]) + assert secho_mock.call_args == mock.call("[planned]", fg="cyan") + assert capsys.readouterr().out == "t " + + def test_render_topic_statuses_colors_every_segment( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """The colored call carries the whole segment sequence of the record.""" + monkeypatch.delenv("NO_COLOR", raising=False) + + with mock.patch.object(render.click, "secho") as secho_mock: + render_topic_statuses([TopicRecord(topic="t", statuses=["done", "mkdocs.published"])]) + assert secho_mock.call_args == mock.call("[done] [mkdocs.published]", fg="cyan") + assert capsys.readouterr().out == "t " + + def test_render_topic_statuses_empty_input_prints_nothing(self, capsys: pytest.CaptureFixture[str]) -> None: + """Empty records render not a single line — an empty result is not an error.""" + render_topic_statuses([]) + assert capsys.readouterr().out == "" + + def test_render_topic_statuses_keeps_input_order( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + """Records print in the given order — the renderer neither sorts nor filters.""" + monkeypatch.setenv("NO_COLOR", "1") + records = [ + TopicRecord(topic="zeta", statuses=["empty"]), + TopicRecord(topic="alpha", statuses=["done"]), + ] + render_topic_statuses(records) + assert capsys.readouterr().out == "zeta [empty]\nalpha [done]\n" + assert [record.topic for record in records] == ["zeta", "alpha"] diff --git a/tests/commands/hooks/__init__.py b/tests/commands/hooks/__init__.py new file mode 100644 index 00000000..86aeb96a --- /dev/null +++ b/tests/commands/hooks/__init__.py @@ -0,0 +1 @@ +"""Tests of the hooks command cell — ``goga/commands/hooks``.""" diff --git a/tests/commands/hooks/test_hooks.py b/tests/commands/hooks/test_hooks.py new file mode 100644 index 00000000..4201dafb --- /dev/null +++ b/tests/commands/hooks/test_hooks.py @@ -0,0 +1,242 @@ +"""Contract and logic tests for the entity declared in +``goga/commands/hooks/CODEMANIFEST`` with ``location: hooks.py`` — +``hooks(tools: tuple[str, ...])``. + +The command is a thin inspection wrapper: it creates the run registry, +assembles it once, applies the ``--tool`` slice, and hands the view to the +renderer. The registry is faked at its import point in the command module, so +the tests drive the CLI surface alone; the command is invoked directly — the +root-group registration belongs to the task that follows this cell. +""" + +from __future__ import annotations + +import inspect +import sys +import typing +from typing import Any + +import click +import pytest +from click.testing import CliRunner +from goga.commands.hooks import hooks +from goga.hooks import ToolHooks +from goga.hooks.tools import RejectedRegistration, Subscription + +# The goga.commands facade re-exports the click command under the same name as +# the cell package (``from .hooks import hooks``), so attribute access through +# goga.commands gives the command for both the package and its module. Resolve +# the real objects via sys.modules (precedent: tests/integration/test_history_command.py). +facade = sys.modules["goga.commands.hooks"] +_hooks_module = sys.modules["goga.commands.hooks.hooks"] + +_CELL_ALL = ["hooks", "render_hooks_tree"] + + +class _FakeRegistry: + """Stand-in for ``HookRegistry`` answering a fixed per-tool view.""" + + def __init__(self, view: list[ToolHooks]) -> None: + self._view = view + self.build_calls = 0 + + def build_once(self) -> None: + self.build_calls += 1 + + def by_tool(self) -> list[ToolHooks]: + return self._view + + +class _BrokenRegistry: + """Stand-in for ``HookRegistry`` whose single build fails fatally.""" + + def build_once(self) -> None: + raise ImportError("package goga_tool_bad failed to import: boom") + + def by_tool(self) -> list[ToolHooks]: + raise AssertionError("a broken build never reaches the view") + + +def _install_fake_registry(monkeypatch: pytest.MonkeyPatch, fake: Any) -> Any: + """Pin the registry factory the command reads; return the fake for asserts.""" + monkeypatch.setattr(_hooks_module, "HookRegistry", lambda: fake) + return fake + + +def _subscription(tool: str = "mkdocs", name: str = "published") -> Subscription: + """One statuses-action subscription — the shape the seed catalog carries.""" + return Subscription( + tool=tool, + domain="statuses", + action="register_statuses", + name=name, + hook=lambda: None, + ) + + +def _dup_rejection(tool: str = "mkdocs") -> RejectedRegistration: + """One refused envelope with the documented repeated-name reason.""" + return RejectedRegistration( + tool=tool, + domain="statuses", + action="register_statuses", + name="dup", + reason="repeated name on the same address", + ) + + +# --- Contract tests --- + + +class TestHooksCommandContract: + def test_hooks_is_exported_by_the_cell_facade(self) -> None: + """``hooks`` is importable from the package and listed in its ``__all__``.""" + assert facade.hooks is hooks + assert list(facade.__all__) == _CELL_ALL + + def test_hooks_is_a_click_command_not_a_group(self) -> None: + """The entity is a terminal ``click.Command`` — no subcommands.""" + assert isinstance(hooks, click.Command) + assert not isinstance(hooks, click.Group) + + def test_hooks_carries_one_repeatable_tools_option(self) -> None: + """``--tool``/``-t`` is the single option, repeatable, never ``None``.""" + options = [param for param in hooks.params if isinstance(param, click.Option)] + assert len(options) == 1 + option = options[0] + assert option.name == "tools" + assert option.multiple is True + assert "--tool" in option.opts + assert "-t" in option.opts + assert option.secondary_opts == [] + + def test_hooks_callback_declares_the_empty_tuple_default(self) -> None: + """No ``-t`` passes the empty tuple — never ``None`` (the click practice).""" + signature = inspect.signature(inspect.unwrap(hooks.callback)) + parameter = signature.parameters["tools"] + assert parameter.default == () + assert parameter.annotation in (tuple[str, ...], "tuple[str, ...]") + + def test_hooks_callback_is_annotated_none(self) -> None: + """The callback returns nothing — the exit code is click's, not a return value.""" + hints = typing.get_type_hints(inspect.unwrap(hooks.callback)) + assert hints["tools"] == tuple[str, ...] + assert hints["return"] is type(None) + + def test_hooks_help_text_carries_no_api_sections(self) -> None: + """``--help`` is user-facing help: no Args/Returns/Raises blocks.""" + result = CliRunner().invoke(hooks, ["--help"]) + + assert result.exit_code == 0 + assert "Usage:" in result.output + assert "--tool" in result.output + for forbidden in ("Args:", "Returns:", "Raises:"): + assert forbidden not in result.output + + def test_hooks_help_summary_is_one_concise_line(self) -> None: + """The first docstring line is the command-listing summary.""" + summary = (hooks.help or "").splitlines()[0] + + assert summary.endswith(".") + assert len(summary) < 80 + + +# --- Logic tests --- + + +class TestHooksCommandTree: + def test_hooks_command_renders_the_tree(self, monkeypatch: pytest.MonkeyPatch) -> None: + """One tool line, domain lines, action lines, then the refusal with its reason.""" + view = [ + ToolHooks( + tool="mkdocs", + subscriptions=[_subscription()], + rejections=[_dup_rejection()], + ) + ] + _install_fake_registry(monkeypatch, _FakeRegistry(view)) + + result = CliRunner().invoke(hooks, []) + + assert result.exit_code == 0 + expected = ( + "mkdocs\n" + " statuses\n" + " register_statuses published\n" + ' rejected statuses/register_statuses "dup": repeated name on the same address\n' + ) + assert result.output == expected + + def test_hooks_assembles_the_registry_once(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The single build of the run — one ``build_once`` call, never two.""" + fake = _FakeRegistry([ToolHooks(tool="mkdocs", subscriptions=[_subscription()], rejections=[])]) + _install_fake_registry(monkeypatch, fake) + + result = CliRunner().invoke(hooks, []) + + assert result.exit_code == 0 + assert fake.build_calls == 1 + + def test_hooks_slice_keeps_empty_entry_for_unknown_tool(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A requested name without registrations keeps its tool line alone.""" + view = [ToolHooks(tool="mkdocs", subscriptions=[_subscription()], rejections=[])] + _install_fake_registry(monkeypatch, _FakeRegistry(view)) + + result = CliRunner().invoke(hooks, ["-t", "mkdocs", "-t", "ghost"]) + + assert result.exit_code == 0 + assert result.output.startswith("mkdocs\n") + assert result.output.endswith("ghost\n") + assert "register_statuses" in result.output + + def test_hooks_slice_deduplicates_a_repeated_request(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Asking for one tool twice yields one tool line, not two.""" + view = [ToolHooks(tool="a", subscriptions=[_subscription(tool="a")], rejections=[])] + _install_fake_registry(monkeypatch, _FakeRegistry(view)) + + result = CliRunner().invoke(hooks, ["-t", "a", "-t", "a"]) + + assert result.exit_code == 0 + assert result.output.splitlines().count("a") == 1 + + def test_hooks_slice_keeps_the_view_order_before_the_missing_names(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Known tools print in view order; unknown names follow in request order.""" + view = [ + ToolHooks(tool="b", subscriptions=[_subscription(tool="b")], rejections=[]), + ToolHooks(tool="a", subscriptions=[_subscription(tool="a")], rejections=[]), + ] + _install_fake_registry(monkeypatch, _FakeRegistry(view)) + + result = CliRunner().invoke(hooks, ["-t", "zulu", "-t", "a", "-t", "b"]) + + assert result.exit_code == 0 + assert result.output.splitlines()[0] == "b" + assert result.output.splitlines()[-1] == "zulu" + + +class TestHooksCommandErrors: + def test_hooks_command_broken_import_is_clean_cli_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A broken package import fails with stderr, exit 1, and no traceback.""" + _install_fake_registry(monkeypatch, _BrokenRegistry()) + + result = CliRunner().invoke(hooks, []) + + assert result.exit_code == 1 + assert "goga_tool_bad" in result.stderr + assert "Traceback" not in result.output + assert "Traceback" not in result.stderr + + +class TestHooksCommandEdges: + def test_hooks_empty_registry_prints_nothing_and_exits_zero(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An empty view renders not a single line and is not an error.""" + _install_fake_registry(monkeypatch, _FakeRegistry([])) + + result = CliRunner().invoke(hooks, []) + + assert result.exit_code == 0 + assert result.output == "" + + def test_hooks_never_emits_an_action(self) -> None: + """Inspection reads the registry only — the module carries no emission.""" + assert not hasattr(_hooks_module, "emit_hook_event") diff --git a/tests/commands/hooks/test_render.py b/tests/commands/hooks/test_render.py new file mode 100644 index 00000000..7bbb7739 --- /dev/null +++ b/tests/commands/hooks/test_render.py @@ -0,0 +1,190 @@ +"""Contract and logic tests for the entity declared in +``goga/commands/hooks/CODEMANIFEST`` with ``location: render.py`` — +``render_hooks_tree(view: list[ToolHooks])``. + +The renderer is pure output: the view prints as given — the tool entries keep +their order, the subscriptions and rejections of an entry are never touched, +and only the domain lines are ordered (alphabetically, per the documented +tree). Output is captured with ``capsys``. +""" + +from __future__ import annotations + +import inspect +import sys +import typing +from collections.abc import Callable + +import pytest +from goga.commands.hooks import render_hooks_tree +from goga.hooks import ToolHooks +from goga.hooks.tools import RejectedRegistration, Subscription + + +def _hook() -> Callable[..., object]: + """A placeholder callable — the renderer never calls a hook.""" + return lambda: None + + +def _subscription( + tool: str = "mkdocs", + domain: str = "statuses", + action: str = "register_statuses", + name: str = "published", +) -> Subscription: + """One accepted subscription bound to an address.""" + return Subscription(tool=tool, domain=domain, action=action, name=name, hook=_hook()) + + +def _rejection( + tool: str = "mkdocs", + domain: str = "statuses", + action: str = "register_statuses", + name: str = "dup", + reason: str = "repeated name on the same address", +) -> RejectedRegistration: + """One refused registration envelope with its reason.""" + return RejectedRegistration(tool=tool, domain=domain, action=action, name=name, reason=reason) + + +# --- Contract tests --- + + +class TestRenderContract: + def test_render_hooks_tree_is_exported_by_the_cell_facade(self) -> None: + """``render_hooks_tree`` is importable from the cell package.""" + # The goga.commands facade re-exports the click command as + # goga.commands.hooks, shadowing the cell package on attribute access — + # resolve the real package via sys.modules (precedent: + # tests/integration/test_history_command.py). + facade = sys.modules["goga.commands.hooks"] + + assert facade.render_hooks_tree is render_hooks_tree + + def test_render_hooks_tree_signature(self) -> None: + """``render_hooks_tree(view: list[ToolHooks]) -> None``.""" + signature = inspect.signature(render_hooks_tree) + assert list(signature.parameters) == ["view"] + assert signature.parameters["view"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + hints = typing.get_type_hints(render_hooks_tree) + assert hints == {"view": list[ToolHooks], "return": type(None)} + + +# --- Logic tests --- + + +class TestRenderTreeFormat: + def test_tool_with_subscriptions_only(self, capsys: pytest.CaptureFixture[str]) -> None: + """A tool line, a domain line per domain, one action line per subscription.""" + view = [ + ToolHooks( + tool="mkdocs", + subscriptions=[ + _subscription(name="published"), + _subscription(name="sync"), + ], + rejections=[], + ) + ] + + render_hooks_tree(view) + + assert capsys.readouterr().out == ( + "mkdocs\n statuses\n register_statuses published\n register_statuses sync\n" + ) + + def test_domain_lines_are_alphabetical_per_tool(self, capsys: pytest.CaptureFixture[str]) -> None: + """The distinct domains of one tool print sorted, whatever the entry order.""" + view = [ + ToolHooks( + tool="t", + subscriptions=[ + _subscription(domain="zeta", action="a", name="n1"), + _subscription(domain="alpha", action="a", name="n2"), + _subscription(domain="zeta", action="b", name="n3"), + ], + rejections=[], + ) + ] + + render_hooks_tree(view) + + assert capsys.readouterr().out == ("t\n alpha\n a n2\n zeta\n a n1\n b n3\n") + + def test_tool_with_a_rejection_only(self, capsys: pytest.CaptureFixture[str]) -> None: + """A refusal prints under its tool with the name in double quotes.""" + view = [ToolHooks(tool="scriba", subscriptions=[], rejections=[_rejection(tool="scriba")])] + + render_hooks_tree(view) + + assert capsys.readouterr().out == ( + 'scriba\n rejected statuses/register_statuses "dup": repeated name on the same address\n' + ) + + def test_tool_without_subscriptions_and_refusals_prints_its_line_alone( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """A sliced empty entry stays in the tree — one bare tool line.""" + view = [ToolHooks(tool="ghost", subscriptions=[], rejections=[])] + + render_hooks_tree(view) + + assert capsys.readouterr().out == "ghost\n" + + def test_empty_view_prints_nothing(self, capsys: pytest.CaptureFixture[str]) -> None: + """An empty registry renders not a single line.""" + render_hooks_tree([]) + assert capsys.readouterr().out == "" + + def test_rejections_print_after_the_domains_of_their_tool(self, capsys: pytest.CaptureFixture[str]) -> None: + """Subscriptions print first, the refusals of the tool after them.""" + view = [ + ToolHooks( + tool="t", + subscriptions=[_subscription()], + rejections=[_rejection()], + ) + ] + + render_hooks_tree(view) + + assert capsys.readouterr().out == ( + "t\n" + " statuses\n" + " register_statuses published\n" + ' rejected statuses/register_statuses "dup": repeated name on the same address\n' + ) + + +class TestRenderReadOnly: + def test_render_hooks_tree_does_not_mutate_or_resort_view(self, capsys: pytest.CaptureFixture[str]) -> None: + """The tool order and the entry contents survive the call unchanged.""" + first = ToolHooks(tool="z", subscriptions=[_subscription(tool="z", name="n1")], rejections=[]) + second = ToolHooks(tool="a", subscriptions=[], rejections=[_rejection(tool="a", name="x")]) + view = [first, second] + snapshot = [ + (entry.tool, [s.name for s in entry.subscriptions], [r.name for r in entry.rejections]) for entry in view + ] + + render_hooks_tree(view) + capsys.readouterr() + + assert [entry.tool for entry in view] == ["z", "a"] + assert [ + (entry.tool, [s.name for s in entry.subscriptions], [r.name for r in entry.rejections]) for entry in view + ] == snapshot + assert view[0] is first + assert view[1] is second + + def test_render_hooks_tree_prints_tools_in_the_given_order(self, capsys: pytest.CaptureFixture[str]) -> None: + """The renderer never re-sorts the tool entries — the caller's order holds.""" + view = [ + ToolHooks(tool="zeta", subscriptions=[_subscription(tool="zeta")], rejections=[]), + ToolHooks(tool="alpha", subscriptions=[_subscription(tool="alpha")], rejections=[]), + ] + + render_hooks_tree(view) + + assert capsys.readouterr().out == ( + "zeta\n statuses\n register_statuses published\nalpha\n statuses\n register_statuses published\n" + ) diff --git a/tests/commands/install/test_hook.py b/tests/commands/install/test_hook.py new file mode 100644 index 00000000..d5710e8f --- /dev/null +++ b/tests/commands/install/test_hook.py @@ -0,0 +1,399 @@ +"""Contract and logic tests for the hook routines declared in +``goga/commands/install/CODEMANIFEST`` with ``location: hook.py``: + +- ``resolve_initiating_user() -> str`` — the actual person behind a possibly + sudo-ed install (``SUDO_USER`` when set and non-empty, else the OS user) +- ``call_install_hook(tool: str, user: str) -> bool`` — dynamic + ``goga_tool_<tool>`` facade import with signature-projected ``user`` + injection +- ``run_install_hooks(tools: list[str]) -> None`` — sequential runner with + one initiating user per run and tool-context failure wrapping + +The facade boundary is mocked at the import boundary per the +hook-fake construction rule: every fake ``install`` is a REAL function +declaring its parameters (a recorder appending to a list), never a bare +MagicMock — ``inspect.signature(MagicMock())`` is ``(*args, **kwargs)`` and +the signature projection would bare-call it. +""" + +from __future__ import annotations + +import inspect +import logging +import sys +import types +import typing +from unittest import mock + +import pytest +from goga.commands.install import hook as hook_module + +# --- Contract tests --- + + +class TestHookContract: + def test_three_routines_exist_and_are_callable(self) -> None: + """The three routines are defined on the hook module and callable.""" + assert callable(hook_module.resolve_initiating_user) + assert callable(hook_module.run_install_hooks) + assert callable(hook_module.call_install_hook) + + def test_module_carries_the_convention_logger(self) -> None: + """``logger = logging.getLogger(__name__)`` — the module's own logger.""" + assert hook_module.logger.name == "goga.commands.install.hook" + + def test_resolve_initiating_user_signature(self) -> None: + """``resolve_initiating_user() -> str``.""" + signature = inspect.signature(hook_module.resolve_initiating_user) + assert list(signature.parameters) == [] + hints = typing.get_type_hints(hook_module.resolve_initiating_user) + assert hints == {"return": str} + + def test_run_install_hooks_signature(self) -> None: + """``run_install_hooks(tools: list[str]) -> None``.""" + signature = inspect.signature(hook_module.run_install_hooks) + assert list(signature.parameters) == ["tools"] + assert signature.parameters["tools"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + hints = typing.get_type_hints(hook_module.run_install_hooks) + assert hints == {"tools": list[str], "return": type(None)} + + def test_call_install_hook_signature(self) -> None: + """``call_install_hook(tool: str, user: str) -> bool``.""" + signature = inspect.signature(hook_module.call_install_hook) + assert list(signature.parameters) == ["tool", "user"] + for parameter in signature.parameters.values(): + assert parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + hints = typing.get_type_hints(hook_module.call_install_hook) + assert hints == {"tool": str, "user": str, "return": bool} + + +# --- Logic tests — resolve_initiating_user --- + + +class TestResolveInitiatingUser: + def test_resolve_initiating_user_prefers_sudo_user(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A set, non-empty ``SUDO_USER`` short-circuits before ``getpass``.""" + monkeypatch.setenv("SUDO_USER", "alice") + + def _raise_unresolvable() -> str: + raise KeyError("no resolvable identity") + + with mock.patch.object(hook_module.getpass, "getuser", side_effect=_raise_unresolvable): + assert hook_module.resolve_initiating_user() == "alice" + + def test_resolve_initiating_user_falls_back_to_os_user(self, monkeypatch: pytest.MonkeyPatch) -> None: + """No (or set-but-empty) ``SUDO_USER`` → the OS user name.""" + monkeypatch.delenv("SUDO_USER", raising=False) + + with mock.patch.object(hook_module.getpass, "getuser", return_value="bob"): + assert hook_module.resolve_initiating_user() == "bob" + # Set but EMPTY is treated as unset — the OS user is the answer too. + monkeypatch.setenv("SUDO_USER", "") + + with mock.patch.object(hook_module.getpass, "getuser", return_value="bob"): + assert hook_module.resolve_initiating_user() == "bob" + + def test_resolve_initiating_user_identity_failure_propagates(self, monkeypatch: pytest.MonkeyPatch) -> None: + """No fallback name is invented when identity resolution fails.""" + monkeypatch.delenv("SUDO_USER", raising=False) + + with ( + mock.patch.object(hook_module.getpass, "getuser", side_effect=KeyError("uid not found")), + pytest.raises(KeyError), + ): + hook_module.resolve_initiating_user() + + +# --- Logic tests — call_install_hook --- + + +class TestCallInstallHook: + def test_call_install_hook_injects_declared_user_keyword(self) -> None: + """A declared keyword-capable ``user`` parameter receives the value.""" + calls: list[dict[str, str | None]] = [] + + def _fake_install(user: str | None = None) -> None: + calls.append({"user": user}) + + fake_module = types.SimpleNamespace(install=_fake_install) + + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module) as mock_import: + invoked = hook_module.call_install_hook("fake", "alice") + assert invoked is True + assert calls == [{"user": "alice"}] + mock_import.assert_called_once_with("goga_tool_fake") + + def test_call_install_hook_normalizes_hyphenated_tool_to_module_name(self) -> None: + """The canonical hyphenated tool name imports the underscored facade module. + + pip lays the ``goga-tool-hello-world`` distribution out as the top-level + module ``goga_tool_hello_world``; a literal ``goga_tool_hello-world`` + import can never resolve and the hook would degrade to the quiet skip. + """ + calls: list[dict[str, str | None]] = [] + + def _fake_install(user: str | None = None) -> None: + calls.append({"user": user}) + + fake_module = types.SimpleNamespace(install=_fake_install) + + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module) as mock_import: + invoked = hook_module.call_install_hook("hello-world", "alice") + assert invoked is True + assert calls == [{"user": "alice"}] + mock_import.assert_called_once_with("goga_tool_hello_world") + + def test_call_install_hook_hyphenated_tool_runs_real_module_hook(self) -> None: + """End-to-end against a real importable facade: the hyphenated spelling runs the hook.""" + calls: list[dict[str, str | None]] = [] + + def _fake_install(user: str | None = None) -> None: + calls.append({"user": user}) + + # importlib.import_module consults sys.modules first, so a registered + # throwaway facade is found without a file on sys.path. + fake_facade = types.SimpleNamespace(install=_fake_install) + + with mock.patch.dict(sys.modules, {"goga_tool_hello_world": fake_facade}): + invoked = hook_module.call_install_hook("hello-world", "alice") + assert invoked is True + assert calls == [{"user": "alice"}] + + def test_call_install_hook_normalizes_case_variant_tool_to_module_name(self) -> None: + """A case-variant tool name imports the lowercase facade module. + + pip resolves distribution names case-insensitively (``goga install + Hello-World`` installs ``goga-tool-hello-world``), so the install + succeeds — the module lookup must reach the same lowercase module or + the hook would degrade to the quiet-skip path. + """ + calls: list[dict[str, str | None]] = [] + + def _fake_install(user: str | None = None) -> None: + calls.append({"user": user}) + + fake_module = types.SimpleNamespace(install=_fake_install) + + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module) as mock_import: + invoked = hook_module.call_install_hook("Hello-World", "alice") + assert invoked is True + assert calls == [{"user": "alice"}] + mock_import.assert_called_once_with("goga_tool_hello_world") + + def test_call_install_hook_case_variant_tool_runs_real_module_hook(self) -> None: + """End-to-end against a real importable facade: the case-variant spelling runs the hook.""" + calls: list[dict[str, str | None]] = [] + + def _fake_install(user: str | None = None) -> None: + calls.append({"user": user}) + + fake_facade = types.SimpleNamespace(install=_fake_install) + + with mock.patch.dict(sys.modules, {"goga_tool_hello_world": fake_facade}): + invoked = hook_module.call_install_hook("Hello-World", "alice") + assert invoked is True + assert calls == [{"user": "alice"}] + + def test_call_install_hook_keyword_only_user_injected(self) -> None: + """``def install(*, user)`` — keyword-only IS keyword-capable (the value is injected).""" + calls: list[dict[str, str | None]] = [] + + def _fake_install(*, user: str | None = None) -> None: + calls.append({"user": user}) + + fake_module = types.SimpleNamespace(install=_fake_install) + + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module): + invoked = hook_module.call_install_hook("fake", "alice") + assert invoked is True + # A bare call would raise TypeError (missing keyword-only argument); + # the recorded value proves the keyword injection happened. + assert calls == [{"user": "alice"}] + + def test_call_install_hook_bare_call_when_no_user_parameter(self) -> None: + """A hook without a ``user`` parameter is called with NO arguments.""" + calls: list[tuple[()]] = [] + + def _fake_install() -> None: + calls.append(()) + + fake_module = types.SimpleNamespace(install=_fake_install) + + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module): + invoked = hook_module.call_install_hook("fake", "alice") + assert invoked is True + # The zero-parameter recorder proves the call carried no arguments — + # any argument would have raised TypeError and propagated. + assert calls == [()] + + def test_call_install_hook_positional_only_user_not_injected(self) -> None: + """``def install(user, /)`` — positional-only is NOT keyword-capable.""" + calls: list[dict[str, str | None]] = [] + + def _fake_install(user: str | None = None, /) -> None: + calls.append({"user": user}) + + fake_module = types.SimpleNamespace(install=_fake_install) + + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module): + invoked = hook_module.call_install_hook("fake", "alice") + assert invoked is True + # The bare call leaves the declared default — "alice" never arrives. + assert calls == [{"user": None}] + + def test_call_install_hook_var_keyword_only_not_injected(self) -> None: + """``def install(**kwargs)`` — ``**kwargs`` is NOT a declared opt-in.""" + calls: list[dict[str, object]] = [] + + def _fake_install(**kwargs: object) -> None: + calls.append(dict(kwargs)) + + fake_module = types.SimpleNamespace(install=_fake_install) + + with mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module): + invoked = hook_module.call_install_hook("fake", "alice") + assert invoked is True + assert calls == [{}] + + def test_call_install_hook_missing_facade_module_skips_quietly(self) -> None: + """A truly missing ``goga_tool_<tool>`` facade is a skip, not a failure.""" + error = ModuleNotFoundError("No module named 'goga_tool_ghost'", name="goga_tool_ghost") + + with mock.patch.object(hook_module.importlib, "import_module", side_effect=error): + assert hook_module.call_install_hook("ghost", "alice") is False + + def test_call_install_hook_broken_facade_import_is_failure_not_skip(self) -> None: + """A DIFFERENT missing module (the facade's own import broke) propagates.""" + error = ModuleNotFoundError("No module named 'dep'", name="dep") + + with ( + mock.patch.object(hook_module.importlib, "import_module", side_effect=error), + pytest.raises(ModuleNotFoundError), + ): + hook_module.call_install_hook("fake", "alice") + + def test_call_install_hook_absent_or_non_callable_install_skips(self) -> None: + """A facade without ``install``, or with a non-callable one, skips.""" + with mock.patch.object(hook_module.importlib, "import_module", return_value=types.SimpleNamespace()): + assert hook_module.call_install_hook("fake", "alice") is False + with mock.patch.object( + hook_module.importlib, + "import_module", + return_value=types.SimpleNamespace(install="not-callable"), + ): + assert hook_module.call_install_hook("fake", "alice") is False + + def test_call_install_hook_hook_exception_propagates_unchanged(self) -> None: + """Whatever the hook itself raises escapes untouched (no wrap here).""" + + def _fake_install(user: str | None = None) -> None: + raise ValueError("boom") + + fake_module = types.SimpleNamespace(install=_fake_install) + + with ( + mock.patch.object(hook_module.importlib, "import_module", return_value=fake_module), + pytest.raises(ValueError, match=r"^boom$"), + ): + hook_module.call_install_hook("fake", "alice") + + +# --- Logic tests — run_install_hooks --- + + +class TestRunInstallHooks: + def test_run_install_hooks_order_and_shared_user(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Hooks run in order, every one seeing the SAME initiating user.""" + log: list[tuple[str, str | None]] = [] + + def _install_a(user: str | None = None) -> None: + log.append(("a", user)) + + def _install_b(user: str | None = None) -> None: + log.append(("b", user)) + + # Real throwaway facades in sys.modules — the real importlib path + # resolves them without touching the filesystem. + monkeypatch.setitem(sys.modules, "goga_tool_a", types.SimpleNamespace(install=_install_a)) + monkeypatch.setitem(sys.modules, "goga_tool_b", types.SimpleNamespace(install=_install_b)) + monkeypatch.delenv("SUDO_USER", raising=False) + + with mock.patch.object(hook_module.getpass, "getuser", return_value="alice"): + hook_module.run_install_hooks(["a", "b"]) + assert log == [("a", "alice"), ("b", "alice")] + + def test_run_install_hooks_stops_at_first_failure_with_tool_context(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The failing hook's exception is wrapped with its tool name; the rest never run.""" + log: list[str] = [] + + def _boom(user: str | None = None) -> None: + raise ValueError("boom") + + def _install_b(user: str | None = None) -> None: + log.append("b") + + monkeypatch.setitem(sys.modules, "goga_tool_a", types.SimpleNamespace(install=_boom)) + monkeypatch.setitem(sys.modules, "goga_tool_b", types.SimpleNamespace(install=_install_b)) + monkeypatch.delenv("SUDO_USER", raising=False) + + with ( + mock.patch.object(hook_module.getpass, "getuser", return_value="alice"), + pytest.raises(RuntimeError) as excinfo, + ): + hook_module.run_install_hooks(["a", "b"]) + assert str(excinfo.value) == "install hook for tool 'a' failed: boom" + assert isinstance(excinfo.value.__cause__, ValueError) + assert log == [] + + def test_run_install_hooks_user_resolution_failure_not_wrapped(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An identity-resolution failure is not a hook failure — no tool context.""" + monkeypatch.delenv("SUDO_USER", raising=False) + + with ( + mock.patch.object(hook_module.getpass, "getuser", side_effect=KeyError("uid not found")), + pytest.raises(KeyError), + ): + hook_module.run_install_hooks(["a"]) + + def test_run_install_hooks_empty_list_is_quiet_noop(self, caplog: pytest.LogCaptureFixture) -> None: + """``[]`` — no user resolution, no import calls, no log lines.""" + with ( + mock.patch.object(hook_module, "resolve_initiating_user") as mock_resolve, + mock.patch.object(hook_module.importlib, "import_module") as mock_import, + ): + hook_module.run_install_hooks([]) + mock_resolve.assert_not_called() + mock_import.assert_not_called() + assert caplog.records == [] + + def test_run_install_hooks_log_levels_invoked_and_skipped( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + """An invoked hook logs INFO; a quiet skip logs DEBUG only.""" + calls: list[str | None] = [] + + def _fake_install(user: str | None = None) -> None: + calls.append(user) + + def _fake_import(name: str) -> types.SimpleNamespace: + if name == "goga_tool_present": + return types.SimpleNamespace(install=_fake_install) + raise ModuleNotFoundError(f"No module named {name!r}", name=name) + + monkeypatch.delenv("SUDO_USER", raising=False) + + with ( + mock.patch.object(hook_module.importlib, "import_module", side_effect=_fake_import), + mock.patch.object(hook_module.getpass, "getuser", return_value="alice"), + caplog.at_level(logging.DEBUG, logger="goga.commands.install.hook"), + ): + hook_module.run_install_hooks(["present", "ghost"]) + assert calls == ["alice"] + invoked = [r for r in caplog.records if r.message == "install hook invoked"] + skipped = [r for r in caplog.records if r.message == "install hook skipped"] + assert len(invoked) == 1 + assert invoked[0].levelno == logging.INFO + assert invoked[0].tool == "present" + assert len(skipped) == 1 + assert skipped[0].levelno == logging.DEBUG + assert skipped[0].tool == "ghost" diff --git a/tests/commands/install/test_install.py b/tests/commands/install/test_install.py index 1c16f3eb..a3bab38c 100644 --- a/tests/commands/install/test_install.py +++ b/tests/commands/install/test_install.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib +import logging import sys from pathlib import Path from unittest import mock @@ -35,6 +36,17 @@ def _pkgs_from_argv(argv: list[str]) -> list[str]: return argv[argv.index("install") + 1 : -1] +# The five names declared in the cell CODEMANIFEST — the two lifecycle commands +# and the three ``hook.py`` routines (exported since release 1.3.0). +_INSTALL_FACADE_ALL = [ + "call_install_hook", + "install", + "resolve_initiating_user", + "run_install_hooks", + "uninstall", +] + + class TestInstallFacade: """Contract tests — verify the install facade and Click command shape.""" @@ -42,14 +54,41 @@ def test_install_importable_from_facade(self) -> None: assert install is not None def test_install_facade_all(self) -> None: - # Access the package module directly to assert its own ``__all__`` - # (``import ... as`` would resolve to the Click command re-exported into - # ``goga.commands``, shadowing the submodule). The facade now carries - # both lifecycle commands of this cell — install and uninstall — pinned - # as the exact surface. ``resolve_version`` belongs to the ``goga/version`` - # domain cell. + """Assert the exact ``__all__`` surface of the package module itself. + + The package module is accessed directly (``import ... as`` would + resolve to the Click command re-exported into ``goga.commands``, + shadowing the submodule). The facade carries the five declared + names — both lifecycle commands of this cell plus the three + ``hook.py`` routines — pinned as the exact surface. + ``resolve_version`` belongs to the ``goga/version`` domain cell. + """ facade = importlib.import_module("goga.commands.install") - assert facade.__all__ == ["install", "uninstall"] + assert facade.__all__ == _INSTALL_FACADE_ALL + + def test_cell_facades_export_full_contract_api(self) -> None: + """Every declared contract name is importable from the cell facade root. + + The Python facade rule obliges ``goga.commands.install`` to expose the + full contract API: both lifecycle commands and the three ``hook.py`` + routines. + """ + from goga.commands.install import ( + call_install_hook, + resolve_initiating_user, + run_install_hooks, + uninstall, + ) + from goga.commands.install import ( + install as install_from_facade, + ) + + assert install_from_facade is install + assert isinstance(uninstall, click.Command) + assert resolve_initiating_user is not None + assert run_install_hooks is not None + assert call_install_hook is not None + assert sys.modules["goga.commands.install"].__all__ == _INSTALL_FACADE_ALL def test_install_is_click_command(self) -> None: assert isinstance(install, click.Command) @@ -485,6 +524,216 @@ def test_install_local_sudo_prepends_sudo_preserve_home(self) -> None: mock_resync.assert_called_once() +class TestInstallHookWiringContract: + """Contract tests — the release adds NO new CLI option, and every pip path + hands ``_after_pip`` a hook-targets list (Algorithm step 4 — HOOKS). + + The hook wiring lives inside the existing surface: single targets + ``[name]``, local the ``:<tool-name>`` suffix value (or an empty list), + bulk the config keys in YAML order. The empty path never reaches hooks. + """ + + def test_install_facade_still_binds_only_the_four_cli_options(self) -> None: + names = {p.name for p in install.params if isinstance(p, click.Option)} + assert names == {"sudo", "version", "local", "no_connect"} + + def test_install_single_path_passes_name_as_hook_targets(self) -> None: + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()), + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents", return_value=0), + ): + result = CliRunner().invoke(app, ["install", "viewer"]) + assert result.exit_code == 0 + mock_hooks.assert_called_once_with(["viewer"]) + + def test_install_local_path_passes_suffix_tool_as_hook_targets(self) -> None: + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()), + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents", return_value=0), + ): + result = CliRunner().invoke(app, ["install", "--local", "./my-tool:mytool"]) + assert result.exit_code == 0 + mock_hooks.assert_called_once_with(["mytool"]) + + def test_install_bulk_path_passes_config_keys_as_hook_targets( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + _write_config(tmp_path, "language: python\ntools:\n viewer: latest\n afm: 1.0.x\n") + monkeypatch.chdir(tmp_path) + + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()), + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents", return_value=0), + ): + result = CliRunner().invoke(app, ["install"]) + assert result.exit_code == 0 + mock_hooks.assert_called_once_with(["viewer", "afm"]) + + +class TestInstallHookFlow: + """HOOKS step — pip → hooks → activation, per-path targets, containment. + + After a successful pip in single, local, and bulk mode ``run_install_hooks`` + receives the path's hook-target list. ``--no-connect`` suppresses only the + re-sync; nothing runs after a failed pip; a hook failure is a user-facing + non-zero exit with no rollback and no re-sync; a malformed ``--local`` + suffix and the empty path never reach hooks at all. + """ + + def test_install_single_mode_runs_hook_after_pip(self) -> None: + order: list[str] = [] + + def _pip(_argv: list[str], **_kwargs: object) -> mock.MagicMock: + order.append("pip") + return _pip_result() + + def _hooks(tools: list[str]) -> None: + order.append(f"hooks:{','.join(tools)}") + + def _resync(_home: Path) -> int: + order.append("resync") + return 0 + + with ( + mock.patch.object(_install_module.subprocess, "run", side_effect=_pip) as mock_run, + mock.patch.object(_install_module, "run_install_hooks", side_effect=_hooks) as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents", side_effect=_resync), + ): + result = CliRunner().invoke(app, ["install", "viewer"]) + assert result.exit_code == 0 + mock_run.assert_called_once() + mock_hooks.assert_called_once_with(["viewer"]) + # The hook step sits between pip and the agent re-sync. + assert order == ["pip", "hooks:viewer", "resync"] + + def test_install_local_with_suffix_targets_tool_hook(self) -> None: + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents", return_value=0), + ): + result = CliRunner().invoke(app, ["install", "--local", "./my-tool:mytool"]) + assert result.exit_code == 0 + # The suffix is stripped from the pip target and names the hook tool. + assert mock_run.call_count == 1 + assert _pkgs_from_argv(mock_run.call_args[0][0]) == ["./my-tool"] + mock_hooks.assert_called_once_with(["mytool"]) + + def test_install_bulk_hooks_follow_yaml_order(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _write_config(tmp_path, "language: python\ntools:\n viewer: latest\n afm: 1.0.x\n") + monkeypatch.chdir(tmp_path) + + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents", return_value=0), + ): + result = CliRunner().invoke(app, ["install"]) + assert result.exit_code == 0 + assert mock_run.call_count == 1 + assert _pkgs_from_argv(mock_run.call_args[0][0]) == ["goga-tool-viewer", "goga-tool-afm~=1.0.0"] + mock_hooks.assert_called_once_with(["viewer", "afm"]) + + def test_install_hook_failure_fails_command_without_rollback_or_resync(self) -> None: + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object( + _install_module, + "run_install_hooks", + side_effect=RuntimeError("install hook for tool 'viewer' failed: boom"), + ), + mock.patch.object(_install_module, "resync_registered_agents") as mock_resync, + ): + result = CliRunner().invoke(app, ["install", "viewer"]) + assert result.exit_code == 1 + assert "install hook for tool 'viewer' failed: boom" in result.output + # No rollback — exactly one pip install, no uninstall anywhere. + assert mock_run.call_count == 1 + mock_resync.assert_not_called() + + def test_install_no_connect_still_runs_hooks(self) -> None: + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()), + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents") as mock_resync, + ): + result = CliRunner().invoke(app, ["install", "viewer", "--no-connect"]) + assert result.exit_code == 0 + mock_hooks.assert_called_once_with(["viewer"]) + # The flag suppresses ONLY the re-sync — the hooks still ran. + mock_resync.assert_not_called() + + def test_install_pip_failure_skips_hooks_and_resync(self) -> None: + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result(2)), + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents") as mock_resync, + ): + result = CliRunner().invoke(app, ["install", "viewer"]) + assert result.exit_code == 2 + mock_hooks.assert_not_called() + mock_resync.assert_not_called() + + @pytest.mark.parametrize( + "argv", + [ + ["--local", "./x:"], + ["--local", "./x:a/b"], + ["--local", "./x:a\\b"], + ["--local", "./x:a:b"], + ], + ) + def test_install_malformed_local_suffix_rejected_before_pip(self, argv: list[str]) -> None: + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + ): + result = CliRunner().invoke(app, ["install", *argv]) + assert result.exit_code == 1 + assert "Error: malformed --local value" in result.output + assert "Traceback" not in result.output + mock_run.assert_not_called() + mock_hooks.assert_not_called() + + def test_install_local_without_suffix_warns_and_runs_no_hook(self, caplog: pytest.LogCaptureFixture) -> None: + with ( + caplog.at_level(logging.WARNING, logger=_install_module.logger.name), + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()), + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents", return_value=0) as mock_resync, + ): + result = CliRunner().invoke(app, ["install", "--local", "./my-tool"]) + assert result.exit_code == 0 + mock_hooks.assert_called_once_with([]) + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert warnings[0].message == "install hook skipped for local source" + assert warnings[0].path == "./my-tool" + assert warnings[0].hint == "pass :<tool-name> to enable the post-install hook" + # A missing suffix suppresses the hook, NOT the activation. + mock_resync.assert_called_once() + + def test_install_empty_mode_touches_nothing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _write_config(tmp_path, "language: python\ntools: {}\n") + monkeypatch.chdir(tmp_path) + + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object(_install_module, "run_install_hooks") as mock_hooks, + mock.patch.object(_install_module, "resync_registered_agents") as mock_resync, + ): + result = CliRunner().invoke(app, ["install"]) + assert result.exit_code == 0 + assert "Nothing to install" in result.output + # The empty path's never-clauses: no pip, no hooks, no activation. + mock_run.assert_not_called() + mock_hooks.assert_not_called() + mock_resync.assert_not_called() + + class TestInstallActivation: """Post-install activation wiring — ``--no-connect`` flag + ACTIVATION step. diff --git a/tests/commands/install/test_integration.py b/tests/commands/install/test_integration.py index bc14e53e..ae996bc3 100644 --- a/tests/commands/install/test_integration.py +++ b/tests/commands/install/test_integration.py @@ -2,12 +2,15 @@ import importlib import sys +import types from pathlib import Path from unittest import mock +import click import pytest from click.testing import CliRunner from goga.cli import app +from goga.commands.install import hook as hook_module _install_module = importlib.import_module("goga.commands.install.install") _uninstall_module = importlib.import_module("goga.commands.install.uninstall") @@ -50,10 +53,11 @@ def test_install_cli_help_lists_options(self) -> None: assert result.exit_code == 0 assert "--sudo" in result.output assert "--version" in result.output - # Click uppercases the argument metavar (NAME) in the usage line and - # emits an ``Arguments:`` section; either marker confirms the required - # positional argument is declared. - assert "NAME" in result.output.upper() or "Arguments" in result.output + # Structural check — the --help prose contains the word "name", so a + # substring assert on the rendered text cannot fail; assert the + # positional argument is declared on the command instead. + arguments = [param for param in _install_module.install.params if isinstance(param, click.Argument)] + assert [param.name for param in arguments] == ["name"] def test_install_cli_plain_dispatch(self) -> None: """``goga install foo`` composes the canonical argv and exits with pip's returncode.""" @@ -226,3 +230,167 @@ def test_install_empty_path_with_sudo_still_empty(self, tmp_path: Path, monkeypa assert result.exit_code == 0 assert result.output.strip() == "Nothing to install" mock_run.assert_not_called() + + +class TestInstallHookFlowIntegration: + """Cross-entity: the real hook routines through the real ``install`` command. + + Only the process boundary is mocked (pip subprocess, the dynamic + ``goga_tool_<tool>`` facade import, the agent re-sync), so these tests + verify the wiring the unit tests mock away: the ``from .hook import`` path + inside ``install.py``, the per-path hook-target lists, the + pip -> hooks -> re-sync order, and the failure containment of the design's + Flows B (single mode, sudo) and C (bulk hook failure). + + The hook-fake construction rule applies: every fake facade is a + ``types.SimpleNamespace`` carrying a REAL recorder function declaring its + parameters — a bare MagicMock has no declared ``user`` parameter and the + signature projection would bare-call it. + """ + + def test_install_hook_flow_b_sudo_user_reaches_hook_in_order(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Flow B: pip -> hook -> re-sync, the hook seeing the REAL person. + + sudo ran and recorded the caller in ``SUDO_USER`` — the hook must + receive ``alice``, the actual person, not the root account the + installer may run under. + """ + monkeypatch.setenv("SUDO_USER", "alice") + order: list[str] = [] + recorder: list[dict[str, str | None]] = [] + + def _viewer_install(user: str | None = None) -> None: + order.append("hook") + recorder.append({"user": user}) + + def _pip(_argv: list[str], **_kwargs: object) -> mock.MagicMock: + order.append("pip") + return _pip_result() + + def _resync(_home: Path) -> int: + order.append("resync") + return 0 + + def _import(name: str) -> types.SimpleNamespace: + if name == "goga_tool_viewer": + return types.SimpleNamespace(install=_viewer_install) + raise ModuleNotFoundError(f"No module named {name!r}", name=name) + + with ( + mock.patch.object(_install_module.subprocess, "run", side_effect=_pip) as mock_run, + mock.patch.object(hook_module.importlib, "import_module", side_effect=_import), + mock.patch.object(_install_module, "resync_registered_agents", side_effect=_resync) as mock_resync, + ): + result = CliRunner().invoke(app, ["install", "viewer"]) + + assert result.exit_code == 0 + mock_run.assert_called_once() + mock_resync.assert_called_once() + # The initiating user is the real person behind the sudo-ed install. + assert recorder == [{"user": "alice"}] + assert order == ["pip", "hook", "resync"] + + def test_install_hook_flow_c_bulk_failure_stops_at_first_hook( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Flow C: bulk stops at the first failing hook — no re-sync, no rollback. + + The viewer hook raises; the wrapped RuntimeError carries the tool name + and hook message out as a user-facing error (exit 1). afm's hook never + runs, the pip package stays, and the agent re-sync is never reached. + """ + _write_config(tmp_path, "language: python\ntools:\n viewer: latest\n afm: 1.0.x\n") + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("SUDO_USER", raising=False) + calls: list[str] = [] + + def _viewer_install(user: str | None = None) -> None: + calls.append("viewer") + raise ValueError("boom") + + def _afm_install(user: str | None = None) -> None: + calls.append("afm") + + def _import(name: str) -> types.SimpleNamespace: + if name == "goga_tool_viewer": + return types.SimpleNamespace(install=_viewer_install) + if name == "goga_tool_afm": + return types.SimpleNamespace(install=_afm_install) + raise ModuleNotFoundError(f"No module named {name!r}", name=name) + + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object(hook_module.getpass, "getuser", return_value="bob"), + mock.patch.object(hook_module.importlib, "import_module", side_effect=_import), + mock.patch.object(_install_module, "resync_registered_agents") as mock_resync, + ): + result = CliRunner().invoke(app, ["install"]) + + assert result.exit_code == 1 + assert "install hook for tool 'viewer' failed: boom" in result.output + # No rollback — exactly one pip install, no uninstall anywhere — and + # bulk stops at the FIRST failing hook: afm's hook never runs. + assert mock_run.call_count == 1 + assert calls == ["viewer"] + mock_resync.assert_not_called() + + def test_install_identity_resolution_failure_is_clean_cli_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An unwrapped hook-step failure (identity resolution) → clean exit 1, no traceback. + + The user-resolution step runs before any hook; its raw failure message + surfaces as a user-facing error (the exit-code matrix's "unwrapped + hook-step failure" row): pip's package stays, no hook runs, and the + activation re-sync is never reached. + """ + monkeypatch.delenv("SUDO_USER", raising=False) + + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object(hook_module.getpass, "getuser", side_effect=KeyError("uid not found")), + mock.patch.object(hook_module.importlib, "import_module") as mock_import, + mock.patch.object(_install_module, "resync_registered_agents") as mock_resync, + ): + result = CliRunner().invoke(app, ["install", "viewer"]) + + assert result.exit_code == 1 + # click's exception formatter produced the message — not a raw traceback. + assert "Error:" in result.output + assert "'uid not found'" in result.output + mock_run.assert_called_once() + mock_import.assert_not_called() + mock_resync.assert_not_called() + + def test_install_local_suffix_hook_end_to_end(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Edge: ``--local <path>:<tool>`` — suffix stripped from pip, names the hook. + + The pip argv carries the bare path, the hook target is the suffixed + tool name, the hook receives the OS user (no ``SUDO_USER``), and the + activation still runs — the suffix governs only the hook, never the + re-sync. + """ + monkeypatch.delenv("SUDO_USER", raising=False) + recorder: list[dict[str, str | None]] = [] + + def _mytool_install(user: str | None = None) -> None: + recorder.append({"user": user}) + + def _import(name: str) -> types.SimpleNamespace: + if name == "goga_tool_mytool": + return types.SimpleNamespace(install=_mytool_install) + raise ModuleNotFoundError(f"No module named {name!r}", name=name) + + with ( + mock.patch.object(_install_module.subprocess, "run", return_value=_pip_result()) as mock_run, + mock.patch.object(hook_module.getpass, "getuser", return_value="bob"), + mock.patch.object(hook_module.importlib, "import_module", side_effect=_import), + mock.patch.object(_install_module, "resync_registered_agents", return_value=0) as mock_resync, + ): + result = CliRunner().invoke(app, ["install", "--local", "./my-tool:mytool"]) + + assert result.exit_code == 0 + # The suffix is stripped from the pip target... + assert mock_run.call_count == 1 + assert _pkgs_from_argv(mock_run.call_args[0][0]) == ["./my-tool"] + # ...and names the hook target, which receives the OS user. + assert recorder == [{"user": "bob"}] + mock_resync.assert_called_once() diff --git a/tests/commands/install/test_uninstall.py b/tests/commands/install/test_uninstall.py index 85cd3072..5d069571 100644 --- a/tests/commands/install/test_uninstall.py +++ b/tests/commands/install/test_uninstall.py @@ -27,10 +27,20 @@ def test_uninstall_importable_from_facade(self) -> None: assert uninstall is not None def test_uninstall_facade_all(self) -> None: - # The install cell facade carries both lifecycle commands — install - # and uninstall — pinned as the exact declared surface. + """The install cell facade carries the five declared names. + + Both lifecycle commands plus the three ``hook.py`` routines — + pinned as the exact surface. Uninstall runs no hooks; only the + ``__all__`` surface it sits on grew in release 1.3.0. + """ facade = importlib.import_module("goga.commands.install") - assert facade.__all__ == ["install", "uninstall"] + assert facade.__all__ == [ + "call_install_hook", + "install", + "resolve_initiating_user", + "run_install_hooks", + "uninstall", + ] def test_uninstall_is_click_command(self) -> None: assert isinstance(uninstall, click.Command) diff --git a/tests/commands/pipeline/test_pipeline_command.py b/tests/commands/pipeline/test_pipeline_command.py index 0da39b80..d9e5f85e 100644 --- a/tests/commands/pipeline/test_pipeline_command.py +++ b/tests/commands/pipeline/test_pipeline_command.py @@ -19,6 +19,7 @@ from __future__ import annotations +import inspect import sys import typing from pathlib import Path @@ -130,6 +131,69 @@ def test_help_lists_list_and_info_flags(self) -> None: assert "--info" in result.output +# --- Contract tests: the --todo flag --- + + +class TestPipelineTodoOptionContract: + """The ``--todo`` option — a flag with no short form, bound as ``todo`` + directly after ``topic`` in the callback signature (contract order). + """ + + def test_pipeline_has_todo_option(self) -> None: + """The pipeline command registers a ``todo`` click Option (``--todo``).""" + param_names = [p.name for p in pipeline.params] + assert "todo" in param_names + + def test_pipeline_todo_option_is_flag_and_defaults_false(self) -> None: + """``--todo`` is a flag defaulting to False.""" + todo_param = next(p for p in pipeline.params if p.name == "todo") + assert isinstance(todo_param, click.Option) + assert todo_param.is_flag is True + assert todo_param.default is False + + def test_pipeline_todo_option_has_no_short_form(self) -> None: + """``--todo`` is long-form only — ``-t`` stays bound to ``--topic``.""" + todo_param = next(p for p in pipeline.params if p.name == "todo") + assert todo_param.opts == ["--todo"] + assert not todo_param.secondary_opts + + topic_param = next(p for p in pipeline.params if p.name == "topic") + assert set(topic_param.opts) == {"-t", "--topic"} + + def test_pipeline_todo_rejects_a_value(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """``--todo`` binds as a flag on the real command — a value is a usage error. + + A synthetic probe command cannot fail from a regression in the + pipeline registration, so the parse surface is pinned against the + real command: a flag fed a value exits 2 (click's usage error) + before any dispatch runs. + """ + _write_config(tmp_path) + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + with mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure: + result = runner.invoke(pipeline, ["development", "--topic", "x", "--todo=text"]) + + assert result.exit_code == 2 + mock_ensure.assert_not_called() + + def test_pipeline_callback_declares_todo_right_after_topic(self) -> None: + """The callback signature carries ``todo: bool`` directly after ``topic``.""" + parameters = list(inspect.signature(pipeline.callback).parameters) + assert parameters.index("todo") == parameters.index("topic") + 1 + + hints = typing.get_type_hints(pipeline.callback) + assert hints["todo"] is bool + + def test_help_lists_todo_flag(self) -> None: + """``--help`` advertises ``--todo``.""" + runner = CliRunner() + result = runner.invoke(pipeline, ["--help"]) + assert result.exit_code == 0 + assert "--todo" in result.output + + # --- Contract obligation --- @@ -591,12 +655,141 @@ def test_pipeline_info_forms_ignore_run_flags(self, tmp_path: Path, monkeypatch: assert mock_info.call_args.kwargs["hosts"] == {} -# --- Facade contract: goga/commands/pipeline exports the info launcher --- +# --- Logic tests: the --todo flag (the step-3 topic procedure) --- + + +class TestPipelineTodoFlag: + """The ``--todo`` surface — the D2 error gate, the verbatim forwarding, + the D7 silent-skip matrix, and the non-TTY abort ordering. + """ + + def test_pipeline_todo_without_topic_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """``--todo`` without ``--topic`` in the run form is a clean error (fix D2). + + Step 2 passes (a name is given), step 3 errors: exit 1 with the exact + message naming ``--topic``, and neither launcher nor the domain + procedure is ever reached — the abort precedes any git or docker + activity. + """ + _write_config(tmp_path) + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + with ( + mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure, + mock.patch.object(_pipeline_module, "run_pipeline_container") as mock_run, + mock.patch.object(_pipeline_module, "run_pipeline_info_container") as mock_info, + ): + result = runner.invoke(pipeline, ["development", "--todo"]) + + assert result.exit_code == 1 + assert result.output == "Error: --todo acts only together with --topic\n" + mock_ensure.assert_not_called() + mock_run.assert_not_called() + mock_info.assert_not_called() + + def test_pipeline_todo_forwarded_to_ensure_topic(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """``--todo`` is forwarded verbatim; the result line echoes once before the dispatch.""" + _write_config(tmp_path) + monkeypatch.chdir(tmp_path) + ensure_line = "Created branch history-com and topic 2026/history-com" + mock_ensure = mock.Mock(return_value=ensure_line) + mock_run = mock.Mock(return_value=3) + order = mock.Mock() + order.attach_mock(mock_ensure, "ensure_topic") + order.attach_mock(mock_run, "run_container") + runner = CliRunner() + + with ( + mock.patch.object(_pipeline_module, "ensure_topic", mock_ensure), + mock.patch.object(_pipeline_module, "run_pipeline_container", mock_run), + ): + result = runner.invoke(pipeline, ["development", "--topic", "history-com", "--todo"]) + + assert result.exit_code == 3 + mock_ensure.assert_called_once_with("history-com", True) + # Exactly one topic line on stdout, and it precedes the docker dispatch. + assert result.stdout.count(ensure_line) == 1 + assert order.method_calls[0] == mock.call.ensure_topic("history-com", True) + assert order.method_calls[1][0] == "run_container" + + @pytest.mark.parametrize( + "argv", + [ + ["--list", "--todo"], + ["development", "--info", "--todo"], + ], + ids=["flat-list", "card"], + ) + def test_pipeline_todo_silently_ignored_in_info_forms( + self, argv: list[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The flat-list and card forms silently skip ``--todo`` (fix D7). + + No error, no topic line, no ``ensure_topic`` call — the flag names no + procedure outside the run form. (The card form ``NAME --info`` is an + info form, not a run form.) + """ + _write_config(tmp_path) + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + with ( + mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure, + mock.patch.object(_pipeline_module, "run_pipeline_info_container", return_value=0) as mock_info, + ): + result = runner.invoke(pipeline, argv) + + assert result.exit_code == 0 + assert result.output == "" + mock_ensure.assert_not_called() + mock_info.assert_called_once() + + def test_pipeline_todo_non_tty_aborts_before_docker(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """``--todo`` on a non-terminal aborts inside the domain before docker. + + The REAL ``ensure_topic`` runs (unmocked): the CliRunner stdin is + never a TTY, so the domain's terminal check fires before any git + action and aborts the command — the entry failure never reaches + docker (no launcher marker touched). + """ + _write_config(tmp_path) + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + with ( + mock.patch.object(_pipeline_module, "run_pipeline_container") as mock_run, + mock.patch.object(_pipeline_module, "run_pipeline_info_container") as mock_info, + ): + result = runner.invoke(pipeline, ["development", "--topic", "x", "--todo"]) + + assert result.exit_code == 1 + assert "interactive terminal" in result.output + mock_run.assert_not_called() + mock_info.assert_not_called() + + +# --- Facade contract: goga/commands/pipeline exports the full contract API --- + +# The five names declared in the cell CODEMANIFEST — the pipeline command, the +# two container launchers, and the two runtime-dir helpers (declared since the +# cell existed, exported since release 1.3.0; the slug transformer and the +# current-branch reader belong to goga.history, and the topic procedure +# delegates to goga.topics.ensure_topic — neither is re-exported from this +# facade; the former branch routines moved to the topics domain in release +# 1.4.0 and are gone from this cell entirely). +_PIPELINE_FACADE_ALL = [ + "clean_pipeline_runtime_dir", + "pipeline", + "resolve_pipeline_runtime_dir", + "run_pipeline_container", + "run_pipeline_info_container", +] class TestCommandsFacadeExportsInfoLauncher: def test_commands_facade_exports_info_launcher(self) -> None: - """The package facade defines all three public names and lists them in ``__all__``. + """The package facade defines all five public names and lists them in ``__all__``. ``goga.commands.pipeline`` is shadowed on the ``goga.commands`` package by the pipeline Click command (see the module-level note above), so the @@ -605,14 +798,66 @@ def test_commands_facade_exports_info_launcher(self) -> None: """ commands_facade = sys.modules["goga.commands.pipeline"] - for name in ("pipeline", "run_pipeline_container", "run_pipeline_info_container"): + for name in _PIPELINE_FACADE_ALL: assert hasattr(commands_facade, name), f"{name} is not defined on goga.commands.pipeline" assert name in commands_facade.__all__, f"{name} is missing from goga.commands.pipeline.__all__" def test_commands_facade_all_is_alphabetical_and_complete(self) -> None: - """``__all__`` holds exactly the three names in alphabetical order.""" + """``__all__`` holds exactly the five names in alphabetical order.""" commands_facade = sys.modules["goga.commands.pipeline"] - assert commands_facade.__all__ == ["pipeline", "run_pipeline_container", "run_pipeline_info_container"] + assert commands_facade.__all__ == _PIPELINE_FACADE_ALL + + def test_cell_facades_export_full_contract_api(self) -> None: + """Every declared contract name is importable from the cell facade root. + + The Python facade rule obliges ``goga.commands.pipeline`` to expose the + full contract API: the command, both launchers, and the two + runtime-dir helpers. + """ + from goga.commands.pipeline import ( + clean_pipeline_runtime_dir, + resolve_pipeline_runtime_dir, + run_pipeline_container, + run_pipeline_info_container, + ) + from goga.commands.pipeline import ( + pipeline as pipeline_from_facade, + ) + + assert pipeline_from_facade is pipeline + assert run_pipeline_container is not None + assert run_pipeline_info_container is not None + assert resolve_pipeline_runtime_dir is not None + assert clean_pipeline_runtime_dir is not None + assert sys.modules["goga.commands.pipeline"].__all__ == _PIPELINE_FACADE_ALL + + def test_cell_facade_holds_no_branch_machinery(self) -> None: + """The retired branch routines are gone from the facade and the package. + + The branch procedure was replaced by the topic procedure + (-t/--topic via ``ensure_topic`` from the topics domain): neither + ``ensure_pipeline_branch`` nor ``check_branch_occupancy`` is defined + on the facade, listed in ``__all__``, or importable as a module of + this cell. + """ + commands_facade = sys.modules["goga.commands.pipeline"] + + assert not hasattr(commands_facade, "ensure_pipeline_branch") + assert not hasattr(commands_facade, "check_branch_occupancy") + assert "ensure_pipeline_branch" not in commands_facade.__all__ + assert "check_branch_occupancy" not in commands_facade.__all__ + assert "goga.commands.pipeline.branch" not in sys.modules + + def test_cell_facade_topic_procedure_imports_from_topics_domain(self) -> None: + """The command module binds ``ensure_topic`` from the topics facade. + + The single identity the topic procedure runs through — the ``from + ...topics import ensure_topic`` import-point the command's own + dispatch relies on. + """ + from goga.topics import ensure_topic as from_domain + + assert _pipeline_module.ensure_topic is from_domain def test_commands_facade_info_launcher_is_importable_by_name(self) -> None: """The consumer form ``from goga.commands.pipeline import run_pipeline_info_container`` works.""" diff --git a/tests/commands/pipeline/test_pipeline_dispatch.py b/tests/commands/pipeline/test_pipeline_dispatch.py index 673b6f9c..d5c63037 100644 --- a/tests/commands/pipeline/test_pipeline_dispatch.py +++ b/tests/commands/pipeline/test_pipeline_dispatch.py @@ -5,28 +5,50 @@ ``pipeline(ctx, name, extra_env, proxy, add_host, clean, update)``: - new ``--proxy``, ``--add-host`` (multiple), ``--clean``, ``--update/-u`` options +- the ``-t/--topic`` option (run form only): one Option with both forms + binding the ``topic`` parameter; the guarded topic procedure runs after + the step-2 validation and before any docker activity, echoes exactly one + stdout line — the result line of ``ensure_topic`` (a switch, or the + creation of fresh work when nothing hosts the identifier) — and never + forwards the topic into a launcher - proxy resolution: ``--proxy`` wins over ``config.pipeline.proxy`` - hosts resolution: ``--add-host`` entries merge on top of ``config.pipeline.hosts`` (CLI overrides config on key conflict) -- dispatch semantics: discovery (``name is None``) forces ``clean=False``; - run mode forwards ``clean``; both modes forward ``proxy``/``hosts``/``update`` +- dispatch semantics: the listing forms take hosts from the config only; + the run form forwards ``clean``; both modes forward + ``proxy``/``hosts``/``update`` - exit code propagated via ``ctx.exit`` The dispatch target ``run_pipeline_container`` is mocked so these tests stay focused on the click surface and the host-side resolution logic, with no docker dependency. + +The integration block at the bottom drives the REAL ``ensure_topic`` from the +topics domain through the real command surface, mocking the domain's git +boundary at its import points (the same wiring the topics cell tests use), to +verify the wiring the unit tests mock away. """ from __future__ import annotations +import inspect import sys +import typing +from pathlib import Path from unittest import mock +import click import pytest from click.testing import CliRunner from goga.commands.pipeline import pipeline from goga.commands.pipeline.pipeline import pipeline as pipeline_cmd from goga.config import BuildConfig, PipelineConfig, ProjectConfig, TaskExecutorConfig +from goga.history import current_year +from goga.topics import board as topics_board +from goga.topics import creation as topics_creation +from goga.topics import ensuring as topics_ensuring +from goga.topics import switching as topics_switching +from goga.topics.git import BranchRef # goga.commands.pipeline.pipeline is shadowed in the package __init__ by the # pipeline Click command, so a string-based mock.patch path walking through it @@ -103,6 +125,43 @@ def test_help_lists_new_options(self) -> None: assert "-u" in output +class TestPipelineTopicOptionContract: + def test_pipeline_topic_option_contract_both_forms_one_option(self) -> None: + """-t/--topic is a single Option binding ``topic``; both forms reach the procedure. + + The Option carries both forms (``set(param.opts)`` is exactly the pair), + defaults to None, and is a plain string option (click renders the + declared ``type=str`` as its canonical STRING param type). The callback + declares ``topic: str | None`` directly after ``info`` (contract + order), and ``--topic x NAME`` / ``-t x NAME`` reach ``ensure_topic`` + with the same value. + """ + topic_param = next(p for p in pipeline.params if p.name == "topic") + assert set(topic_param.opts) == {"-t", "--topic"} + assert topic_param.default is None + assert topic_param.type is click.STRING + + parameters = list(inspect.signature(pipeline_cmd.callback).parameters) + assert parameters.index("topic") == parameters.index("info") + 1 + hints = typing.get_type_hints(pipeline_cmd.callback) + assert hints["topic"] == str | None + assert "branch" not in parameters + + config = _make_config() + runner = CliRunner() + + for argv in (["--topic", "x", "my-pipeline"], ["-t", "x", "my-pipeline"]): + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(_pipeline_module, "ensure_topic", return_value="Switched to branch x") as mock_ensure, + mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0), + ): + result = runner.invoke(pipeline, argv) + + assert result.exit_code == 0 + mock_ensure.assert_called_once_with("x", False) + + # --- Logic tests (positive) --- @@ -252,6 +311,272 @@ def test_pipeline_propagates_exit_code(self, exit_code: int) -> None: assert result.exit_code == exit_code +class TestPipelineTopicRunForm: + def test_pipeline_topic_option_switches_before_docker(self) -> None: + """The topic procedure runs, echoes its one result line, then the container launches.""" + config = _make_config() + switch_line = "Switched to branch feat/x" + mock_ensure = mock.Mock(return_value=switch_line) + mock_run = mock.Mock(return_value=0) + order = mock.Mock() + order.attach_mock(mock_ensure, "ensure_topic") + order.attach_mock(mock_run, "run_container") + runner = CliRunner() + + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(_pipeline_module, "ensure_topic", mock_ensure), + mock.patch.object(_pipeline_module, "run_pipeline_container", mock_run), + ): + result = runner.invoke(pipeline, ["-t", "feat/x", "development"]) + + assert result.exit_code == 0 + # Exactly one topic line on stdout, verbatim from ensure_topic. + assert result.stdout.count(switch_line) == 1 + mock_ensure.assert_called_once_with("feat/x", False) + mock_run.assert_called_once() + assert mock_run.call_args.kwargs["name"] == "development" + # The topic procedure precedes the docker activity, and the topic + # identifier never crosses the docker boundary. + assert order.method_calls[0] == mock.call.ensure_topic("feat/x", False) + assert order.method_calls[1][0] == "run_container" + assert "topic" not in mock_run.call_args.kwargs + assert "feat/x" not in mock_run.call_args.kwargs.values() + + @pytest.mark.parametrize( + "argv", + [ + ["-t", "x", "--list"], + ["-t", "x", "--list", "--info"], + ["-t", "x", "my-pipeline", "--info"], + ], + ids=["flat-list", "overview", "card"], + ) + def test_pipeline_topic_ignored_in_list_and_info_forms(self, argv: list[str]) -> None: + """The flat list, overview, and card forms ignore -t — no procedure, no line.""" + config = _make_config() + runner = CliRunner() + + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure, + mock.patch.object(_pipeline_module, "run_pipeline_info_container", return_value=0) as mock_info, + ): + result = runner.invoke(pipeline, argv) + + assert result.exit_code == 0 + mock_ensure.assert_not_called() + mock_info.assert_called_once() + assert "Switched to branch" not in result.stdout + + def test_pipeline_missing_name_with_topic_no_switch(self) -> None: + """A step-2 form error exits 1 before any git action of the topic procedure.""" + config = _make_config() + runner = CliRunner() + + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure, + mock.patch.object(_pipeline_module, "run_pipeline_container") as mock_run, + ): + result = runner.invoke(pipeline, ["-t", "x"]) + + assert result.exit_code == 1 + assert "Missing pipeline name" in result.output + mock_ensure.assert_not_called() + mock_run.assert_not_called() + + def test_pipeline_has_no_branch_option(self) -> None: + """-b is gone from the surface: unknown option, and absent from --help.""" + config = _make_config() + runner = CliRunner() + + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(_pipeline_module, "ensure_topic") as mock_ensure, + ): + result = runner.invoke(pipeline, ["-b", "x", "dev"]) + + assert result.exit_code != 0 + mock_ensure.assert_not_called() + + help_result = runner.invoke(pipeline, ["--help"]) + assert help_result.exit_code == 0 + assert "-b" not in help_result.output + assert "--branch" not in help_result.output + + +# --- Integration tests (the real topics-domain switch through the real command) --- + + +def _trees_reader(trees: dict[str, list[str]]): + """A ``read_ref_tree_paths`` stand-in answering by ref display name.""" + + def read(ref: str, prefix: str) -> list[str]: + return [path for path in trees.get(ref, []) if path.startswith(prefix)] + + return read + + +def _wire_topic_domain( + monkeypatch: pytest.MonkeyPatch, + inventory: list[BranchRef], + trees: dict[str, list[str]], + current: str | None, +) -> tuple[mock.Mock, mock.Mock, mock.Mock, mock.Mock]: + """Wire the REAL ``ensure_topic`` to a canned git boundary. + + The resolution reads the scale, the ref inventory, the ref trees, and the + current branch at their import points inside the topics domain (the same + points the domain's own tests patch); the switch mutations are recording + mocks. The fast creation of ``ensure_topic`` at zero candidates runs the + REAL occupancy oracles — so the creation module's git boundary (the + inventory, the current branch, and the branch-tree slug oracle) is wired + the same way — and the REAL topic-directory creation; only its + create-and-switch mutation is a recording mock at ``ensuring``'s import + point. Only the topics facade stays real — exactly the wiring + ``pipeline`` relies on through ``from ...topics import ensure_topic``. + + Returns: + The cleanliness probe, the local checkout, the remote-tracking branch + creation, and the create-and-switch mutation of the fast creation + — all as recording mocks. + """ + monkeypatch.setattr(topics_switching, "assemble_status_scale", _builtin_scale) + monkeypatch.setattr(topics_switching, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(topics_switching, "resolve_current_branch_name", lambda: current) + monkeypatch.setattr(topics_board, "read_ref_tree_paths", _trees_reader(trees)) + + cleanliness = mock.Mock(return_value=True) + checkout = mock.Mock() + remote_creation = mock.Mock() + monkeypatch.setattr(topics_switching, "is_working_tree_clean", cleanliness) + monkeypatch.setattr(topics_switching, "checkout_local_branch", checkout) + monkeypatch.setattr(topics_switching, "create_branch_from_remote_tracking", remote_creation) + + monkeypatch.setattr(topics_creation, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(topics_creation, "resolve_current_branch_name", lambda: current) + monkeypatch.setattr(topics_creation, "read_ref_tree_paths", _trees_reader(trees)) + create_and_switch = mock.Mock() + monkeypatch.setattr(topics_ensuring, "create_and_switch_branch", create_and_switch) + return cleanliness, checkout, remote_creation, create_and_switch + + +def _builtin_scale(): + """The deterministic builtin scale for the resolution (no tool packages).""" + from goga.history.statuses import Stage, StatusScale + + return StatusScale( + stages=[ + Stage(name="empty", filepath=""), + Stage(name="defined", filepath="prd.md"), + Stage(name="discovered", filepath="adr.md"), + Stage(name="backlog", filepath="task.md"), + Stage(name="designed", filepath="arch.md"), + Stage(name="specified", filepath="design.md"), + Stage(name="planned", filepath="plan.md"), + Stage(name="done", filepath="completed/plan.md"), + ] + ) + + +class TestPipelineTopicIntegration: + """Cross-entity: the real ``ensure_topic`` from the topics domain through the real command. + + Only the domain's git boundary is canned, so these tests verify the wiring + the unit tests mock away: the ``from ...topics import`` path, the run-form + guard, the argument handed to the domain, and the ordering guarantee — + step-2 validation, topic procedure, topic line, docker activity. + """ + + def test_pipeline_topic_flow_switches_and_launches(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Flow happy path: the domain switches, its line prints, the launcher runs topic-free.""" + monkeypatch.chdir(tmp_path) + year = current_year() + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="feat/a", remote=False), + ] + trees = {"feat/a": [f".goga/history/{year}/feat-a/plan.md"]} + _cleanliness, checkout, _remote, _fresh = _wire_topic_domain(monkeypatch, inventory, trees, "main") + + config = _make_config() + runner = CliRunner() + + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0) as mock_run, + ): + result = runner.invoke(pipeline, ["-t", "feat/a", "my-pipeline"]) + + assert result.exit_code == 0 + assert "Switched to branch feat/a" in result.stdout + checkout.assert_called_once_with("feat/a") + assert mock_run.call_count == 1 + assert mock_run.call_args.kwargs["name"] == "my-pipeline" + # The topic identifier never crosses the docker boundary. + assert "topic" not in mock_run.call_args.kwargs + assert "feat/a" not in mock_run.call_args.kwargs.values() + + def test_pipeline_topic_idempotent_host_skips_git_mutations( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Already on the host branch: the confirmation line prints and git is untouched.""" + monkeypatch.chdir(tmp_path) + year = current_year() + inventory = [BranchRef(name="feat/a", remote=False)] + trees = {"feat/a": [f".goga/history/{year}/feat-a/plan.md"]} + cleanliness, checkout, remote_creation, fresh_creation = _wire_topic_domain( + monkeypatch, inventory, trees, "feat/a" + ) + + config = _make_config() + runner = CliRunner() + + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0) as mock_run, + ): + result = runner.invoke(pipeline, ["-t", "feat/a", "my-pipeline"]) + + assert result.exit_code == 0 + assert "Already on branch feat/a" in result.stdout + cleanliness.assert_not_called() + checkout.assert_not_called() + remote_creation.assert_not_called() + fresh_creation.assert_not_called() + assert mock_run.call_count == 1 + + def test_pipeline_topic_unresolved_identifier_creates_and_launches( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An identifier nothing hosts: fresh work is created — the branch as + entered, the topic directory of the year — the creation line prints, + and the launcher runs topic-free.""" + monkeypatch.chdir(tmp_path) + year = current_year() + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": [f".goga/history/{year}/other/prd.md"]} + _cleanliness, checkout, _remote, fresh_creation = _wire_topic_domain(monkeypatch, inventory, trees, "main") + + config = _make_config() + runner = CliRunner() + + with ( + mock.patch.object(_pipeline_module, "load_project_config", return_value=config), + mock.patch.object(_pipeline_module, "run_pipeline_container", return_value=0) as mock_run, + ): + result = runner.invoke(pipeline, ["-t", "nope", "my-pipeline"]) + + assert result.exit_code == 0 + assert f"Created branch nope and topic {year}/nope" in result.stdout + fresh_creation.assert_called_once_with("nope") + checkout.assert_not_called() + assert (tmp_path / ".goga" / "history" / year / "nope").is_dir() + assert mock_run.call_count == 1 + assert "topic" not in mock_run.call_args.kwargs + + class TestPipelineCallbackSignature: def test_pipeline_callback_has_new_parameters(self) -> None: """The decorated callback exposes proxy/add_host/clean/update parameters.""" @@ -260,3 +585,9 @@ def test_pipeline_callback_has_new_parameters(self) -> None: assert "add_host" in parameters assert "clean" in parameters assert "update" in parameters + + def test_pipeline_callback_declares_topic_not_branch(self) -> None: + """The callback signature carries ``topic: str | None`` and no ``branch``.""" + hints = typing.get_type_hints(pipeline_cmd.callback) + assert hints["topic"] == str | None + assert "branch" not in hints diff --git a/tests/commands/test_build.py b/tests/commands/test_build.py index 9ffdaebc..191acc4d 100644 --- a/tests/commands/test_build.py +++ b/tests/commands/test_build.py @@ -77,9 +77,9 @@ def test_build_plan_is_required(self, tmp_path, monkeypatch) -> None: assert result.exit_code == 2 assert "Missing argument" in result.output - def test_build_has_fifteen_options(self) -> None: + def test_build_has_sixteen_options(self) -> None: options = [p for p in build_cmd.params if isinstance(p, click.Option)] - assert len(options) == 15 + assert len(options) == 16 def test_build_has_dry_run_option(self) -> None: param_names = [p.name for p in build_cmd.params] @@ -121,6 +121,10 @@ def test_build_has_review_patience_option(self) -> None: param_names = [p.name for p in build_cmd.params] assert "review_patience" in param_names + def test_build_has_base_ref_option(self) -> None: + param_names = [p.name for p in build_cmd.params] + assert "base_ref" in param_names + def test_build_has_skip_review_option(self) -> None: param_names = [p.name for p in build_cmd.params] assert "skip_review" in param_names @@ -146,6 +150,7 @@ def test_help_contains_all_options(self) -> None: "--wait", "--max-iterations", "--review-patience", + "--base-ref", "--skip-review", "--no-skip-review", "-e", @@ -608,6 +613,36 @@ def test_skip_finalize_forwarded(self, mock_env, mock_docker, tmp_path, monkeypa assert "--skip-finalize" in args +# --- _cli_flags_to_args base_ref forwarding tests --- + + +class TestCliFlagsToArgsBaseRef: + """--base-ref renders as a value-option pair; unset adds no token. + + The host performs no precedence resolution for the review diff base — + forwarding only. A set value yields the exact ``["--base-ref", REF]`` + tokens docker run hands to the container argv; unset (None) adds no + flag, leaving the decision to the container config. + """ + + def test_cli_flags_to_args_base_ref_set(self) -> None: + from goga.commands.build.build import _cli_flags_to_args + + args = _cli_flags_to_args({"base_ref": "origin/1.2.x", "dry_run": False}) + + assert "--base-ref" in args + assert "origin/1.2.x" in args + + def test_cli_flags_to_args_base_ref_unset_adds_no_flag(self) -> None: + from goga.commands.build.build import _cli_flags_to_args + + args = _cli_flags_to_args({"base_ref": None}) + + assert "--base-ref" not in args + # No bare token leaks: every rendered token belongs to another flag. + assert args == [] + + # --- Git config tests --- diff --git a/tests/commands/test_commands_facade.py b/tests/commands/test_commands_facade.py index ee87fe35..bf4c5278 100644 --- a/tests/commands/test_commands_facade.py +++ b/tests/commands/test_commands_facade.py @@ -1,13 +1,17 @@ from __future__ import annotations import click -from goga import commands +from goga import app, commands +from goga.commands import hooks as hooks_reexport from goga.commands import install as install_reexport from goga.commands import pipeline as pipeline_reexport +from goga.commands import topics as topics_reexport from goga.commands import uninstall as uninstall_reexport +from goga.commands.hooks import hooks as hooks_source from goga.commands.install import install as install_source from goga.commands.install import uninstall as uninstall_source from goga.commands.pipeline import pipeline as pipeline_source +from goga.commands.topics import topics as topics_source class TestCommandsFacade: @@ -52,3 +56,32 @@ def test_uninstall_listed_in_all(self) -> None: def test_uninstall_is_a_click_command_via_facade(self) -> None: """The re-exported uninstall is a click.Command.""" assert isinstance(commands.uninstall, click.Command) + + +class TestTopicsFacade: + def test_topics_reexported_from_facade(self) -> None: + """topics is importable from the goga.commands facade.""" + assert topics_reexport is topics_source + + def test_topics_listed_in_all(self) -> None: + """topics is a declared member of the goga.commands facade.""" + assert "topics" in commands.__all__ + + def test_topics_is_a_click_group_via_facade(self) -> None: + """The re-exported topics is a click Group (a command group).""" + assert isinstance(topics_reexport, click.Group) + + +class TestHooksFacade: + def test_commands_facade_reexports_hooks_and_root_group_registers_it(self) -> None: + """hooks is re-exported by the facade and registered on the root app group. + + The full registration chain of the hooks inspection command: the facade + re-export carries the source object, ``__all__`` declares it, the + re-export is a plain ``click.Command`` (not a group), and the root + ``app`` carries it under its command name. + """ + assert hooks_reexport is hooks_source + assert "hooks" in commands.__all__ + assert isinstance(hooks_reexport, click.Command) + assert any(cmd.name == "hooks" for cmd in app.commands.values()) diff --git a/tests/commands/topics/__init__.py b/tests/commands/topics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/commands/topics/test_render.py b/tests/commands/topics/test_render.py new file mode 100644 index 00000000..843ec936 --- /dev/null +++ b/tests/commands/topics/test_render.py @@ -0,0 +1,429 @@ +"""Contract and logic tests for the entities declared in +``goga/commands/topics/CODEMANIFEST`` with ``location: render.py``: + +- ``render_topic_board(records: list[BoardRecord], width: int, info: bool = False)`` + +The board renderer is pure output: the collected records print as given — +no sorting, no filtering, no mutation — as a three-column table of topic, +branch, and statuses, or a four-column table with the todo column between +branch and statuses under ``info``; the widths follow the thirds or the +quarters arithmetic of the active column rule. Output is captured with +``capsys``. +""" + +from __future__ import annotations + +import inspect +import re +import typing + +import pytest +from goga.commands.topics import render, render_topic_board +from goga.topics import BoardRecord + +# --- Contract tests --- + + +class TestRenderContract: + def test_entity_is_importable_from_facade_and_callable(self) -> None: + """``render_topic_board`` is importable from ``goga.commands.topics``.""" + assert render.render_topic_board is render_topic_board + assert callable(render_topic_board) + + def test_render_topic_board_signature(self) -> None: + """``render_topic_board(records: list[BoardRecord], width: int, info: bool = False)``.""" + signature = inspect.signature(render_topic_board) + assert list(signature.parameters) == ["records", "width", "info"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["info"].default is False + hints = typing.get_type_hints(render_topic_board) + assert hints == {"records": list[BoardRecord], "width": int, "info": bool, "return": type(None)} + + def test_render_topic_board_empty_input_prints_nothing(self, capsys: pytest.CaptureFixture[str]) -> None: + """An empty board renders not a single line — header included.""" + render_topic_board([], 80) + assert capsys.readouterr().out == "" + + def test_render_topic_board_info_header_is_the_word_todo(self, capsys: pytest.CaptureFixture[str]) -> None: + """Under ``info`` the third column header is the word ``todo``. + + The header cell pads to its column cap, so the assertion matches the + literal ``| todo`` run followed by padding spaces and the divider — + a bare substring check would miss the padded cell. + """ + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["todo"], + current=False, + remote=False, + todo="Pay retry cap", + ) + ] + render_topic_board(records, 100, info=True) + header_line = capsys.readouterr().out.splitlines()[0] + assert re.search(r"\| todo\s+\|", header_line) + + +# --- Logic tests --- + + +class TestRenderTopicBoard: + def test_render_topic_board_widths_and_wrap(self, capsys: pytest.CaptureFixture[str]) -> None: + """Width 60 — thirds of 17, overlong cells truncated, statuses wrapped.""" + records = [ + BoardRecord( + topic="very-long-topic-name-123", + branch="feat/very-long-branch", + statuses=["planned", "mkdocs.published"], + current=False, + remote=False, + ) + ] + render_topic_board(records, 60) + lines = capsys.readouterr().out.splitlines() + # usable = 51, so topic_cap = branch_cap = 17 and statuses_w = 17; + # every grid line stays within the measured width; the closing row + # divider adds the fifth line. + assert len(lines) == 5 + assert all(len(line) <= 60 for line in lines) + assert lines[0].startswith("| Topic") + assert "Branch" in lines[0] + assert "Statuses" in lines[0] + assert set(lines[1]) == {"-", "|"} + # The topic and the branch exceed 17 columns — both carry the ellipsis. + assert "…" in lines[2] + # The first segment prints whole; the second one is 18 > 17 and + # therefore appears truncated on the continuation line. + assert "[planned]" in lines[2] + assert "mkdocs.publis" in lines[3] + assert "…" in lines[3] + # The grid survives the wrap — empty topic and branch continuation cells. + assert lines[3].startswith(f"|{' ' * 19}|{' ' * 19}|") + # Read-only — the renderer does not mutate, re-sort, or filter the input. + assert records[0].topic == "very-long-topic-name-123" + assert records[0].statuses == ["planned", "mkdocs.published"] + + def test_render_topic_board_current_asterisk_and_empty(self, capsys: pytest.CaptureFixture[str]) -> None: + """The current row carries the ``* `` marker; an empty board prints nothing.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["planned"], + current=True, + remote=False, + ) + ] + render_topic_board(records, 80) + captured = capsys.readouterr() + assert any(line.startswith("| * feat-a") for line in captured.out.splitlines()) + assert "[planned]" in captured.out + render_topic_board([], 80) + assert capsys.readouterr().out == "" + + def test_render_topic_board_degenerate_narrow_terminal(self, capsys: pytest.CaptureFixture[str]) -> None: + """Width 20 — every column keeps its minimum of 8, the table may exceed.""" + records = [ + BoardRecord( + topic="a-very-long-topic", + branch="feat/x", + statuses=["done"], + current=False, + remote=False, + ) + ] + render_topic_board(records, 20) + lines = capsys.readouterr().out.splitlines() + # Three minimum-8 columns plus the grid overhead of 9 — the table is + # 33 columns wide on a 20-column terminal: minimum readability wins. + assert all(len(line) == 33 for line in lines) + # Truncation still applies — the 17-column topic does not fit 8. + assert "…" in lines[2] + assert "feat/x" in lines[2] + assert "[done]" in lines[2] + + @pytest.mark.parametrize("width", [33, 32]) + def test_render_topic_board_boundary_width_33_32(self, capsys: pytest.CaptureFixture[str], width: int) -> None: + """Width 33 splits evenly into the minimum thirds; 32 stays at them anyway.""" + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["done"], current=False, remote=False), + BoardRecord( + topic="a-very-long-topic-name", branch="feat/x", statuses=["done"], current=False, remote=False + ), + ] + render_topic_board(records, width) + lines = capsys.readouterr().out.splitlines() + # Both boundaries resolve to the 8/8/8 minimum layout — width 33 fits + # the table exactly; width 32 is the documented one-column overflow. + assert all(len(line) == 33 for line in lines) + assert "feat-a" in lines[2] + assert "[done]" in lines[2] + assert "…" in lines[4] + + def test_render_topic_board_two_segments_fit_one_line(self, capsys: pytest.CaptureFixture[str]) -> None: + """Width 80 — two short status segments join on one statuses line.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["defined", "planned"], + current=False, + remote=False, + ) + ] + render_topic_board(records, 80) + lines = capsys.readouterr().out.splitlines() + # usable = 71, so topic_cap = branch_cap = 23 and statuses_w = 25; + # "[defined] [planned]" is 19 columns and fits — one data row plus + # its closing row divider. + assert len(lines) == 4 + assert "[defined] [planned]" in lines[2] + assert all(len(line) <= 80 for line in lines) + + def test_render_topic_board_three_columns_unchanged_without_info(self, capsys: pytest.CaptureFixture[str]) -> None: + """Regression gate — without ``info`` the three-column output is byte-identical.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["defined", "planned"], + todo="Pay retry cap", + current=False, + remote=False, + ), + BoardRecord( + topic="a-very-long-topic-name", + branch="feat/x", + statuses=["done"], + todo=None, + current=False, + remote=False, + ), + ] + render_topic_board(records, 100) + first = capsys.readouterr().out + render_topic_board(records, 100, info=False) + second = capsys.readouterr().out + # The explicit False and the default produce the very same bytes. + assert first == second + lines = first.splitlines() + # usable = 91, so topic_cap = branch_cap = 30 and statuses_w = 31; + # the header keeps the three columns — the todo stays invisible. + assert lines[0].startswith("| Topic") + assert not re.search(r"\| todo\s+\|", lines[0]) + assert "Statuses" in lines[0] + assert all(len(line) <= 100 for line in lines) + assert "Pay retry cap" not in first + + @pytest.mark.parametrize("width", [33, 32]) + def test_render_topic_board_three_column_narrow_threshold( + self, capsys: pytest.CaptureFixture[str], width: int + ) -> None: + """Widths 33 and 32 without ``info`` stay on the 8/8/8 minimum thirds.""" + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["done"], todo="T", current=False, remote=False) + ] + render_topic_board(records, width) + lines = capsys.readouterr().out.splitlines() + # The narrow threshold of the three-column rule is 33 usable columns — + # both boundaries resolve to the 8/8/8 minimum layout. + assert all(len(line) == 33 for line in lines) + assert lines[0].startswith("| Topic") + assert not re.search(r"\| todo\s+\|", lines[0]) + + +class TestRenderTopicBoardInfo: + def test_render_topic_board_info_four_columns(self, capsys: pytest.CaptureFixture[str]) -> None: + """Width 100 under ``info`` — quarters of 22, the todo column visible.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["planned"], + todo="Pay retry cap", + current=False, + remote=False, + ), + BoardRecord( + topic="feat-b", + branch="feat/b", + statuses=["done"], + todo="an-overlong-todo-summary-exceeding-cap", + current=False, + remote=False, + ), + ] + render_topic_board(records, 100, info=True) + lines = capsys.readouterr().out.splitlines() + # usable = 88, so every column takes a quarter — 22/22/22/22. + assert lines[0].startswith("| Topic") + assert "Branch" in lines[0] + assert re.search(r"\| todo\s+\|", lines[0]) + assert "Statuses" in lines[0] + for line in lines: + assert line.count("|") == 4 + assert all(len(line) <= 100 for line in lines) + assert "Pay retry cap" in lines[2] + # The 37-column summary exceeds its cap of 22 — truncated with the + # ellipsis on the second record row, past the divider between the + # two records. + assert "…" in lines[4] + assert "[planned]" in lines[2] + assert "[done]" in lines[4] + + def test_render_info_column_carries_todo_header(self, capsys: pytest.CaptureFixture[str]) -> None: + """The ``info`` header carries the word todo and the record's todo summary.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["todo"], + current=False, + remote=False, + todo="Pay retry cap", + ) + ] + render_topic_board(records, 100, info=True) + lines = capsys.readouterr().out.splitlines() + # usable = 88, so every text column takes a quarter — (100 - 12) // 4 = 22. + header_line = lines[0] + assert re.search(r"\| todo\s+\|", header_line) + assert "Pay retry cap" in lines[2] + + @pytest.mark.parametrize("todo", [None, ""]) + def test_render_todo_none_renders_empty_cell(self, capsys: pytest.CaptureFixture[str], todo: str | None) -> None: + """A todo of None or of the empty string renders an empty padded cell.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["planned"], + todo=todo, + current=False, + remote=False, + ) + ] + render_topic_board(records, 100, info=True) + lines = capsys.readouterr().out.splitlines() + # The cell between branch and statuses is the whitespace padding of a + # 22-column cell — no text. + cells = lines[2][2:-1].split(" | ") + assert len(cells) == 4 + assert cells[2] == " " * 22 + assert len(lines[2]) == 100 + + @pytest.mark.parametrize("width", [44, 43]) + def test_render_topic_board_info_boundary_widths_44_43( + self, capsys: pytest.CaptureFixture[str], width: int + ) -> None: + """Widths 44 and 43 under ``info`` — the narrow threshold of the quarters.""" + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["done"], todo="T", current=False, remote=False) + ] + render_topic_board(records, width, info=True) + lines = capsys.readouterr().out.splitlines() + # usable = width - 12: 44 gives exactly 32 = 8x4 — the table fits the + # width; 43 gives 31 < 32 — the documented one-column overflow. + assert all(len(line) == 44 for line in lines) + assert lines[0].startswith("| Topic") + assert re.search(r"\| todo\s+\|", lines[0]) + assert "[done]" in lines[2] + + def test_render_topic_board_info_wraps_statuses_with_empty_leading_cells( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Wrapped statuses under ``info`` continue on three empty leading cells.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["done", "planned"], + todo="T", + current=False, + remote=False, + ) + ] + render_topic_board(records, 44, info=True) + lines = capsys.readouterr().out.splitlines() + # usable = 32 = 8x4 — the minimum quarters; "[done]" and the + # truncated "[planned]" cannot share the 8-column statuses cell; + # the closing row divider adds the fifth line. + assert len(lines) == 5 + assert "[done]" in lines[2] + assert "[planne…" in lines[3] + # The continuation row keeps the grid: topic, branch, and todo are + # the empty padding of their columns — 10 columns per leading cell. + assert lines[3].startswith(f"|{' ' * 10}|{' ' * 10}|{' ' * 10}|") + assert len(lines[3]) == 44 + + def test_render_topic_board_info_empty_records_print_nothing(self, capsys: pytest.CaptureFixture[str]) -> None: + """An empty board under ``info`` renders not a single line — header included.""" + render_topic_board([], 100, info=True) + assert capsys.readouterr().out == "" + + +class TestRenderTopicBoardRowDividers: + def test_render_topic_board_row_divider_closes_every_record(self, capsys: pytest.CaptureFixture[str]) -> None: + """A row divider — the header separator's own line — closes every record. + + Two records print header, header separator, row, divider, row, + divider: the divider after the last record included, and every + divider byte-identical to the header separator. + """ + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=False, remote=False), + BoardRecord(topic="feat-b", branch="feat/b", statuses=["done"], current=False, remote=False), + ] + render_topic_board(records, 80) + lines = capsys.readouterr().out.splitlines() + assert len(lines) == 6 + assert lines[0].startswith("| Topic") + # The dividers sit after every record — the last one included. + assert lines[3] == lines[1] + assert lines[5] == lines[1] + assert set(lines[3]) == {"-", "|"} + # The record rows themselves stay put between the dividers. + assert "feat-a" in lines[2] + assert "feat-b" in lines[4] + + def test_render_topic_board_continuation_lines_stay_undivided(self, capsys: pytest.CaptureFixture[str]) -> None: + """The wrapped statuses of one record stay undivided — the divider + closes the whole record, not every grid line.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["planned", "mkdocs.published"], + current=False, + remote=False, + ) + ] + render_topic_board(records, 60) + lines = capsys.readouterr().out.splitlines() + # usable = 51 — the 18-column "[mkdocs.published]" wraps: the + # continuation line directly follows its record row, and the single + # divider closes the record after the continuation. + assert lines[3].startswith(f"|{' ' * 19}|{' ' * 19}|") + assert "mkdocs.publis" in lines[3] + assert lines[4] == lines[1] + + def test_render_topic_board_info_row_divider_closes_every_record(self, capsys: pytest.CaptureFixture[str]) -> None: + """Under ``info`` the dividers keep the four-pipe grid of the quarters.""" + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], todo="T", current=False, remote=False), + BoardRecord(topic="feat-b", branch="feat/b", statuses=["done"], todo=None, current=False, remote=False), + ] + render_topic_board(records, 100, info=True) + lines = capsys.readouterr().out.splitlines() + assert len(lines) == 6 + assert lines[3] == lines[1] + assert lines[5] == lines[1] + for line in lines: + assert line.count("|") == 4 + assert all(len(line) <= 100 for line in lines) + assert "feat-a" in lines[2] + assert "feat-b" in lines[4] diff --git a/tests/commands/topics/test_topics.py b/tests/commands/topics/test_topics.py new file mode 100644 index 00000000..38b88123 --- /dev/null +++ b/tests/commands/topics/test_topics.py @@ -0,0 +1,961 @@ +"""Contract and logic tests for the entity declared in +``goga/commands/topics/CODEMANIFEST`` with ``location: topics.py``: +the ``topics`` click group with the ``board``/``create``/``switch``/ +``delete`` subcommands. + +The group is a thin wrapper: the ``--year/-y`` option builds the scope +every subcommand shares, and each subcommand delegates its computation +to the ``goga.topics`` domain — the board collection and rendering for +``board`` (the ``--info/-i`` flag adds the todo column to the rendered +table), the creation and switching procedures for ``create``/``switch`` +(``--todo/-t`` is a plain value option whose empty value counts as +absent; the editor entry itself belongs to the domain), and the +resolution plus confirmed removal for ``delete`` (one confirmation for +the whole list). The creation inputs resolve at this layer: the base — +``--base-ref``, the ``topics`` section of ``.goga/config.yml``, +``--from-current`` — and the message template — ``--commit/-c``, the +``topics`` section, the domain default — the configuration being read +lazily, only for values no flag provided. The logic tests mock the +domain at its import site in the command module and drive the CLI +surface through ``CliRunner``; a pinned ``COLUMNS`` keeps the measured +terminal width deterministic, and the configuration cases run against a +``tmp_path`` cwd. +""" + +from __future__ import annotations + +import inspect +import io +import os +import shutil +import sys +from pathlib import Path +from unittest import mock + +import click +import pytest +from click.testing import CliRunner +from goga.commands.topics import render_topic_board, topics +from goga.topics import BoardRecord, DeleteTarget + +# goga.commands.topics.topics is shadowed in the package __init__ by the +# topics click group, so attribute access through the package gives the +# group. Resolve the real module via sys.modules (precedent: test_history). +_topics_module = sys.modules["goga.commands.topics.topics"] +# The facade __all__ lives on the cell package itself. +_topics_facade = sys.modules["goga.commands.topics"] + + +class _TtyStdin(io.BytesIO): + """A CliRunner input whose isatty() is True — models the confirm gate's terminal. + + CliRunner's isolation replaces ``sys.stdin`` around every invoke, so a + patched ``sys.stdin`` never survives into the command; an ``input=`` + stream does — click accepts it as the binary reader directly and the + TextIOWrapper it builds delegates ``isatty()`` to it. + """ + + def isatty(self) -> bool: + return True + + +# --- Contract tests --- + + +class TestTopicsGroupContract: + def test_topics_importable_from_facade(self) -> None: + """topics is importable from the goga.commands.topics facade.""" + assert _topics_module.topics is topics + + def test_facade_exports_two_names(self) -> None: + """The cell facade carries the two declared names, alphabetically.""" + assert _topics_facade.__all__ == ["render_topic_board", "topics"] + assert callable(topics) + assert callable(render_topic_board) + + def test_topics_is_a_click_group(self) -> None: + """topics is a click.Group container for the subcommands.""" + assert isinstance(topics, click.Group) + + def test_topics_registers_four_subcommands(self) -> None: + """The group carries exactly the four declared subcommands.""" + assert sorted(topics.commands) == ["board", "create", "delete", "switch"] + + def test_topics_group_carries_the_year_option(self) -> None: + """The group owns the shared --year/-y option, defaulting to None.""" + assert len(topics.params) == 1 + year_option = next(p for p in topics.params if isinstance(p, click.Option) and p.name == "year") + assert "-y" in year_option.opts + assert "--year" in year_option.opts + assert year_option.default is None + + def test_topics_group_callback_signature(self) -> None: + """``topics(ctx, year)`` — the context and the scoped year.""" + callback = topics.callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["ctx", "year"] + assert signature.parameters["year"].default is None + + def test_scope_is_a_kw_only_dataclass_with_year(self) -> None: + """``_TopicsScope`` is a kw_only dataclass carrying the year field.""" + scope = _topics_module._TopicsScope(year="2025") + assert scope.year == "2025" + assert _topics_module._TopicsScope().year is None + + def test_board_callback_signature(self) -> None: + """``board(scope, remote=False, info=False)`` — the scope object and the flags.""" + callback = topics.commands["board"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["scope", "remote", "info"] + assert signature.parameters["remote"].default is False + assert signature.parameters["info"].default is False + + def test_board_carries_the_remote_flag(self) -> None: + """board: --remote/-r flag, defaulting to False.""" + command = topics.commands["board"] + remote_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "remote") + assert "-r" in remote_option.opts + assert "--remote" in remote_option.opts + assert remote_option.is_flag is True + assert remote_option.default is False + + def test_board_carries_the_info_flag(self) -> None: + """board: --info/-i flag, defaulting to False.""" + command = topics.commands["board"] + info_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "info") + assert "-i" in info_option.opts + assert "--info" in info_option.opts + assert info_option.is_flag is True + assert info_option.default is False + + def test_create_carries_the_name_positional(self) -> None: + """create: the required branch_name positional.""" + command = topics.commands["create"] + argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "branch_name") + assert argument.required is True + + def test_create_todo_option_surface(self) -> None: + """create: --todo/-t is a plain value option — no optional-value flag.""" + command = topics.commands["create"] + todo_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "todo") + assert "-t" in todo_option.opts + assert "--todo" in todo_option.opts + assert todo_option.is_flag is False + assert todo_option.default is None + # No optional-value flag: a value-less --todo is a usage error, not + # an entry marker (click keeps an UNSET sentinel here, not a value). + assert not todo_option.secondary_opts + + def test_create_carries_the_publish_flag(self) -> None: + """create: --publish/-p flag, defaulting to False.""" + command = topics.commands["create"] + publish_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "publish") + assert "-p" in publish_option.opts + assert "--publish" in publish_option.opts + assert publish_option.is_flag is True + assert publish_option.default is False + + def test_create_carries_the_base_ref_option(self) -> None: + """create: --base-ref option, long form only, defaulting to None.""" + command = topics.commands["create"] + base_ref_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "base_ref") + assert base_ref_option.opts == ["--base-ref"] + assert base_ref_option.is_flag is False + assert base_ref_option.default is None + + def test_create_carries_the_from_current_flag(self) -> None: + """create: --from-current flag, long form only, defaulting to False.""" + command = topics.commands["create"] + from_current_option = next( + p for p in command.params if isinstance(p, click.Option) and p.name == "from_current" + ) + assert from_current_option.opts == ["--from-current"] + assert from_current_option.is_flag is True + assert from_current_option.default is False + + def test_create_carries_the_commit_option_with_the_explicit_param_name(self) -> None: + """create: --commit/-c bound to the param name ``commit_message``.""" + command = topics.commands["create"] + commit_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "commit_message") + assert commit_option.opts == ["--commit", "-c"] + assert commit_option.is_flag is False + assert commit_option.default is None + + def test_create_carries_the_switch_flag(self) -> None: + """create: --switch/-s flag, defaulting to False.""" + command = topics.commands["create"] + switch_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "switch") + assert "-s" in switch_option.opts + assert "--switch" in switch_option.opts + assert switch_option.is_flag is True + assert switch_option.default is False + + def test_create_callback_signature(self) -> None: + """``create(scope, branch_name, todo, publish, base_ref, from_current, commit_message, switch)``.""" + callback = topics.commands["create"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == [ + "scope", + "branch_name", + "todo", + "publish", + "base_ref", + "from_current", + "commit_message", + "switch", + ] + assert signature.parameters["todo"].default is None + assert signature.parameters["publish"].default is False + assert signature.parameters["base_ref"].default is None + assert signature.parameters["from_current"].default is False + assert signature.parameters["commit_message"].default is None + assert signature.parameters["switch"].default is False + + def test_switch_carries_the_identifier_positional(self) -> None: + """switch: the required identifier positional.""" + command = topics.commands["switch"] + argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "identifier") + assert argument.required is True + + def test_switch_carries_the_todo_flag(self) -> None: + """switch: --todo flag, long form only, defaulting to False.""" + command = topics.commands["switch"] + todo_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "todo") + assert todo_option.opts == ["--todo"] + assert todo_option.is_flag is True + assert todo_option.default is False + + def test_switch_callback_signature(self) -> None: + """``switch(scope, identifier, todo=False)``.""" + callback = topics.commands["switch"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["scope", "identifier", "todo"] + assert signature.parameters["todo"].default is False + + def test_delete_carries_the_identifiers_positionals(self) -> None: + """delete: the required variadic identifiers positional.""" + command = topics.commands["delete"] + argument = next(p for p in command.params if isinstance(p, click.Argument) and p.name == "identifiers") + assert argument.required is True + assert argument.nargs == -1 + + def test_delete_carries_the_yes_flag(self) -> None: + """delete: --yes/-y flag, defaulting to False.""" + command = topics.commands["delete"] + yes_option = next(p for p in command.params if isinstance(p, click.Option) and p.name == "yes") + assert "-y" in yes_option.opts + assert "--yes" in yes_option.opts + assert yes_option.is_flag is True + assert yes_option.default is False + + def test_delete_callback_signature(self) -> None: + """``delete(scope, identifiers, yes=False)``.""" + callback = topics.commands["delete"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["scope", "identifiers", "yes"] + assert signature.parameters["yes"].default is False + + def test_prompt_multiline_and_default_publish_commit_are_gone(self) -> None: + """The abolished CLI-layer entry, template constant, and publish edge no longer exist.""" + assert not hasattr(_topics_module, "_prompt_multiline") + assert not hasattr(_topics_module, "_DEFAULT_PUBLISH_COMMIT") + assert not hasattr(_topics_module, "publish_topic") + + +# --- Logic tests --- + + +class TestTopicsGroupSurface: + def test_topics_group_help_and_year_scope(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """--help lists the subcommands and --year/-y; the scope reaches the domain.""" + monkeypatch.chdir(tmp_path) + runner = CliRunner() + result = runner.invoke(topics, ["--help"]) + assert result.exit_code == 0 + assert "Work with the topics of one year." in result.output + for subcommand in ("board", "create", "switch", "delete"): + assert subcommand in result.output + assert "--year" in result.output + assert "-y" in result.output + + with mock.patch.object(_topics_module, "create_topic") as mock_create: + mock_create.return_value = "Created branch X and topic 2025/x" + scoped = runner.invoke(topics, ["--year", "2025", "create", "X", "--from-current"]) + assert scoped.exit_code == 0 + mock_create.assert_called_once_with("X", "HEAD", None, False, None, "2025", False) + + @pytest.mark.parametrize("subcommand", ["board", "create", "switch", "delete"]) + def test_subcommand_help_follows_the_cli_docstring_rule(self, subcommand: str) -> None: + """The rendered help carries no Args/Returns/Raises sections.""" + result = CliRunner().invoke(topics, [subcommand, "--help"]) + assert result.exit_code == 0 + assert result.output.strip() != "" + for section in ("Args:", "Returns:", "Raises:"): + assert section not in result.output + + def test_create_help_lists_the_new_flags(self) -> None: + """create --help lists --todo/-t, --publish/-p, --base-ref, --from-current, --commit/-c, and --switch/-s.""" + result = CliRunner().invoke(topics, ["create", "--help"]) + assert result.exit_code == 0 + assert "--todo" in result.output + assert "-t" in result.output + assert "--publish" in result.output + assert "-p" in result.output + assert "--base-ref" in result.output + assert "--from-current" in result.output + assert "--commit" in result.output + assert "-c" in result.output + assert "--switch" in result.output + assert "-s" in result.output + + def test_delete_help_lists_the_surface(self) -> None: + """delete --help lists --yes/-y and the IDENTIFIERS argument.""" + result = CliRunner().invoke(topics, ["delete", "--help"]) + assert result.exit_code == 0 + assert "--yes" in result.output + assert "-y" in result.output + assert "IDENTIFIERS" in result.output + + def test_year_defaults_to_none_for_the_domain(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Without --year the subcommands hand the domain the current-year None.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(_topics_module, "create_topic") as mock_create: + mock_create.return_value = "Created branch X and topic 2026/x" + result = CliRunner().invoke(topics, ["create", "X", "--from-current"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with("X", "HEAD", None, False, None, None, False) + + +class TestTopicsBoard: + def test_board_collects_and_renders_the_board(self) -> None: + """board hands the domain (scope.year, remote) and renders the records.""" + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=True, remote=False), + ] + + with ( + mock.patch.object(_topics_module, "collect_topic_board", return_value=records) as mock_collect, + mock.patch.dict("os.environ", {"COLUMNS": "100"}), + ): + result = CliRunner().invoke(topics, ["board"]) + assert result.exit_code == 0 + mock_collect.assert_called_once_with(None, False) + assert "feat-a" in result.output + assert "feat/a" in result.output + assert "[planned]" in result.output + assert "| Topic" in result.output + + def test_board_passes_the_year_and_the_remote_flag(self) -> None: + """--year and --remote/-r reach the domain call verbatim.""" + with ( + mock.patch.object(_topics_module, "collect_topic_board", return_value=[]) as mock_collect, + mock.patch.dict("os.environ", {"COLUMNS": "100"}), + ): + result = CliRunner().invoke(topics, ["--year", "2025", "board", "--remote"]) + assert result.exit_code == 0 + mock_collect.assert_called_once_with("2025", True) + + def test_board_short_forms_bind_the_same_values(self) -> None: + """-y and -r behave exactly like their long forms.""" + with ( + mock.patch.object(_topics_module, "collect_topic_board", return_value=[]) as mock_collect, + mock.patch.dict("os.environ", {"COLUMNS": "100"}), + ): + result = CliRunner().invoke(topics, ["-y", "2024", "board", "-r"]) + assert result.exit_code == 0 + mock_collect.assert_called_once_with("2024", True) + + def test_topics_board_info_flag_reaches_renderer(self, monkeypatch: pytest.MonkeyPatch) -> None: + """--info reaches the renderer — the table gains the todo column.""" + records = [ + BoardRecord( + topic="feat-a", + branch="feat/a", + statuses=["planned"], + current=True, + remote=False, + todo="Payment retry", + ), + ] + # The lambda tolerates any caller signature: pytest's own terminal + # writer probes the width with ``fallback=`` while the patch is live, + # and a zero-arg patch aborts the run as an INTERNALERROR. + monkeypatch.setattr(shutil, "get_terminal_size", lambda *_args, **_kwargs: os.terminal_size((100, 24))) + + with mock.patch.object(_topics_module, "collect_topic_board", return_value=records): + result = CliRunner().invoke(topics, ["board", "--info"]) + assert result.exit_code == 0 + header = result.output.splitlines()[0] + assert "todo" in header + assert "Topic" in header + assert "Branch" in header + assert "Statuses" in header + assert "Payment retry" in result.output + + def test_topics_board_info_short_form_binds_the_same_table(self) -> None: + """-i renders the same four-column table as --info.""" + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=False, remote=False, todo="T"), + ] + + with ( + mock.patch.object(_topics_module, "collect_topic_board", return_value=records), + mock.patch.dict("os.environ", {"COLUMNS": "100"}), + ): + short = CliRunner().invoke(topics, ["board", "-i"]) + long = CliRunner().invoke(topics, ["board", "--info"]) + assert short.exit_code == 0 + assert long.exit_code == 0 + assert short.output == long.output + assert "todo" in short.output.splitlines()[0] + + def test_board_empty_board_prints_nothing_exit_zero(self) -> None: + """An empty board is not an error — nothing on stdout, exit 0.""" + with ( + mock.patch.object(_topics_module, "collect_topic_board", return_value=[]), + mock.patch.dict("os.environ", {"COLUMNS": "100"}), + ): + result = CliRunner().invoke(topics, ["board"]) + assert result.exit_code == 0 + assert result.output == "" + + @pytest.mark.parametrize(("columns", "expected"), [(40, 40), (30, 33)]) + def test_board_measures_the_terminal_width(self, columns: int, expected: int) -> None: + """The render width is the measured terminal width, not a constant.""" + records = [ + BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=False, remote=False), + ] + + with ( + mock.patch.object(_topics_module, "collect_topic_board", return_value=records), + mock.patch.dict("os.environ", {"COLUMNS": str(columns)}), + ): + result = CliRunner().invoke(topics, ["board"]) + assert result.exit_code == 0 + # Width 40 lays out in thirds — the table fits it exactly; width 30 + # is the documented ultra-narrow exception where the minimum 8/8/8 + # layout of 33 columns wins. Either way the measurement was taken. + assert result.output.splitlines() != [] + assert all(len(line) == expected for line in result.output.splitlines()) + + def test_board_domain_error_surfaces_clean(self) -> None: + """A domain ClickException propagates as stderr + exit 1, no traceback.""" + with mock.patch.object( + _topics_module, + "collect_topic_board", + side_effect=click.ClickException("no branch hosts 'x' — run 'goga topics board' to see the board"), + ): + result = CliRunner().invoke(topics, ["board"]) + assert result.exit_code == 1 + assert "no branch hosts" in result.stderr + assert "Traceback" not in result.stderr + assert result.stdout == "" + + +def _write_config(tmp_path: Path, body: str) -> None: + """Write ``.goga/config.yml`` with the given body under tmp_path.""" + goga_dir = tmp_path / ".goga" + goga_dir.mkdir(exist_ok=True) + (goga_dir / "config.yml").write_text(body, encoding="utf-8") + + +class TestTopicsCreateAndSwitch: + def test_create_echoes_the_domain_result_line(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """create echoes the single result line and exits 0.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object( + _topics_module, + "create_topic", + return_value="Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar", + ) as mock_create: + result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "--base-ref", "origin/main"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with("Feature/Foo_Bar", "origin/main", None, False, None, None, False) + assert result.output.splitlines() == ["Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar"] + + def test_topics_create_todo_option_reaches_domain(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """-t hands the domain (name, HEAD, todo, publish, template, year) verbatim.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + result = CliRunner().invoke(topics, ["create", "Feature/Foo_Bar", "--from-current", "-t", "Payment retry"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with("Feature/Foo_Bar", "HEAD", "Payment retry", False, None, None, False) + assert result.output == "line\n" + + def test_topics_create_todo_long_form_binds_the_same_value( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """--todo behaves exactly like -t.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + result = CliRunner().invoke(topics, ["create", "feat-a", "--base-ref", "origin/main", "--todo", "T"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with("feat-a", "origin/main", "T", False, None, None, False) + assert result.output == "line\n" + + @pytest.mark.parametrize( + "flag_form", + [["--todo", "Payment retry"], ["--todo=Payment retry"], ["-t", "Payment retry"], ["-tPayment retry"]], + ) + def test_create_flag_with_value_passes_todo( + self, flag_form: list[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Every flag form carrying a value hands the domain the todo verbatim.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + result = CliRunner().invoke(topics, ["create", "feat-a", "--base-ref", "origin/main", *flag_form]) + assert result.exit_code == 0 + assert mock_create.call_args == mock.call("feat-a", "origin/main", "Payment retry", False, None, None, False) + + def test_create_empty_todo_value_counts_as_absent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An explicitly empty --todo value is None at the call — never an entry marker. + + The CliRunner stdin is never a TTY, which is the point: without a + value option there is no CLI-side entry that could need one. + """ + monkeypatch.chdir(tmp_path) + + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + result = CliRunner().invoke(topics, ["create", "feat-a", "--from-current", "--todo", ""]) + assert result.exit_code == 0 + mock_create.assert_called_once_with("feat-a", "HEAD", None, False, None, None, False) + + @pytest.mark.parametrize("flag_form", [["--todo"], ["-t"]]) + def test_create_bare_todo_flag_is_usage_error(self, flag_form: list[str]) -> None: + """A value-less --todo is click's own usage error — no optional-value flag reappears.""" + with mock.patch.object(_topics_module, "create_topic") as mock_create: + result = CliRunner().invoke(topics, ["create", "feat-a", "--base-ref", "origin/main", *flag_form]) + assert result.exit_code == 2 + assert "requires an argument" in result.output + mock_create.assert_not_called() + + def test_switch_echoes_the_domain_result_line(self) -> None: + """switch echoes the single result line and exits 0.""" + with mock.patch.object( + _topics_module, + "switch_topic", + return_value="Switched to branch feat/a", + ) as mock_switch: + result = CliRunner().invoke(topics, ["switch", "feat-a"]) + assert result.exit_code == 0 + mock_switch.assert_called_once_with("feat-a", False, None) + assert result.output.splitlines() == ["Switched to branch feat/a"] + + def test_switch_receives_the_scoped_year(self) -> None: + """--year reaches switch_topic verbatim.""" + with mock.patch.object(_topics_module, "switch_topic", return_value="Already on branch feat/a") as mock_switch: + result = CliRunner().invoke(topics, ["--year", "2025", "switch", "feat-a"]) + assert result.exit_code == 0 + mock_switch.assert_called_once_with("feat-a", False, "2025") + assert result.output.splitlines() == ["Already on branch feat/a"] + + @pytest.mark.parametrize( + ("argv", "argument"), + [ + (["create"], "branch_name"), + (["switch"], "identifier"), + (["delete"], "identifiers"), + ], + ) + def test_missing_positional_is_usage_error(self, argv: list[str], argument: str) -> None: + """A missing positional is click's own usage error — exit 2, no domain call.""" + with ( + mock.patch.object(_topics_module, "create_topic") as mock_create, + mock.patch.object(_topics_module, "switch_topic") as mock_switch, + mock.patch.object(_topics_module, "resolve_delete_targets") as mock_resolve, + ): + result = CliRunner().invoke(topics, argv) + assert result.exit_code == 2 + assert argument.upper() in result.output + mock_create.assert_not_called() + mock_switch.assert_not_called() + mock_resolve.assert_not_called() + + @pytest.mark.parametrize( + ("argv", "routine"), + [ + (["create", "x", "--base-ref", "origin/main"], "create_topic"), + (["switch", "x"], "switch_topic"), + ], + ) + def test_domain_error_surfaces_clean( + self, argv: list[str], routine: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A domain ClickException propagates as stderr + exit 1, no traceback.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(_topics_module, routine, side_effect=click.ClickException("working tree is dirty")): + result = CliRunner().invoke(topics, argv) + assert result.exit_code == 1 + assert "working tree is dirty" in result.stderr + assert "Traceback" not in result.stderr + assert result.stdout == "" + + +class TestTopicsCreateBaseResolution: + def test_create_base_ref_flag_beats_config_beats_from_current( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The base matrix: --base-ref beats topics.base_ref beats --from-current.""" + monkeypatch.chdir(tmp_path) + _write_config( + tmp_path, + "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: cfg tpl\n", + ) + + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + flag_base = CliRunner().invoke(topics, ["create", "n1", "--base-ref", "origin/flag-base"]) + config_base = CliRunner().invoke(topics, ["create", "n2"]) + assert flag_base.exit_code == 0 + assert config_base.exit_code == 0 + # n1: the base flag wins; the template still comes from the config. + assert mock_create.call_args_list[0] == mock.call("n1", "origin/flag-base", None, False, "cfg tpl", None, False) + assert mock_create.call_args_list[1] == mock.call( + "n2", "origin/config-base", None, False, "cfg tpl", None, False + ) + + # A config without topics.base_ref: --from-current yields the HEAD. + _write_config(tmp_path, "language: python\n") + + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + from_current = CliRunner().invoke(topics, ["create", "n3", "--from-current"]) + assert from_current.exit_code == 0 + assert mock_create.call_args == mock.call("n3", "HEAD", None, False, None, None, False) + + # A --commit flag beats the config template (publication-only, so + # under --publish). + _write_config( + tmp_path, + "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: cfg tpl\n", + ) + + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + flag_template = CliRunner().invoke(topics, ["create", "n4", "--publish", "-t", "T", "--commit", "x {slug}"]) + assert flag_template.exit_code == 0 + assert mock_create.call_args == mock.call("n4", "origin/config-base", "T", True, "x {slug}", None, False) + + # A missing configuration file counts as unset — the lazy read + # tolerates it and --from-current still yields the HEAD. + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + monkeypatch.chdir(empty_dir) + + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + missing = CliRunner().invoke(topics, ["create", "n5", "--from-current"]) + assert missing.exit_code == 0 + assert mock_create.call_args == mock.call("n5", "HEAD", None, False, None, None, False) + + def test_create_no_base_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Nothing set: the error names --base-ref, --from-current, and the config line.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(_topics_module, "create_topic") as mock_create: + result = CliRunner().invoke(topics, ["create", "name"]) + assert result.exit_code == 1 + assert "--base-ref" in result.stderr + assert "--from-current" in result.stderr + assert "topics.base_ref" in result.stderr + assert "Traceback" not in result.stderr + mock_create.assert_not_called() + + def test_create_from_current_passes_head(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """--from-current passes the literal string HEAD — no CLI-side resolution.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + result = CliRunner().invoke(topics, ["create", "name", "--from-current"]) + assert result.exit_code == 0 + assert mock_create.call_args.args[1] == "HEAD" + + def test_create_commit_without_publish_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """--commit without --publish is a clean error; --base-ref alone is not.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(_topics_module, "create_topic") as mock_create: + result = CliRunner().invoke(topics, ["create", "--base-ref", "origin/main", "--commit", "x", "name"]) + assert result.exit_code == 1 + assert "--commit" in result.stderr + assert "publication-only" in result.stderr + mock_create.assert_not_called() + + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + base_alone = CliRunner().invoke(topics, ["create", "--base-ref", "origin/main", "name"]) + assert base_alone.exit_code == 0 + mock_create.assert_called_once_with("name", "origin/main", None, False, None, None, False) + + def test_create_switch_with_publish_is_clean_error(self) -> None: + """--switch together with --publish is a clean error — the publication never switches.""" + with mock.patch.object(_topics_module, "create_topic") as mock_create: + result = CliRunner().invoke( + topics, + ["create", "X", "--publish", "--switch", "-t", "T", "--base-ref", "origin/main"], + ) + assert result.exit_code == 1 + assert "--switch" in result.stderr + assert "--publish" in result.stderr + assert "never switches" in result.stderr + mock_create.assert_not_called() + + def test_create_switch_flag_forwarded(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """--switch (either form) reaches the domain as the switch positional True.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + short_form = CliRunner().invoke(topics, ["create", "feat-a", "--base-ref", "origin/main", "-s", "-t", "T"]) + long_form = CliRunner().invoke( + topics, ["create", "feat-a", "--base-ref", "origin/main", "--switch", "-t", "T"] + ) + assert short_form.exit_code == 0 + assert long_form.exit_code == 0 + assert mock_create.call_args_list == [ + mock.call("feat-a", "origin/main", "T", False, None, None, True), + mock.call("feat-a", "origin/main", "T", False, None, None, True), + ] + + def test_create_both_values_given_reads_no_configuration( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Both --base-ref and --commit given: the flag values win and no config read happens.""" + monkeypatch.chdir(tmp_path) + _write_config( + tmp_path, + "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: 'config: {slug}'\n", + ) + + with ( + mock.patch.object(_topics_module, "load_project_config") as mock_load, + mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create, + ): + result = CliRunner().invoke( + topics, + [ + "create", + "Feature/Foo_Bar", + "--publish", + "-t", + "T", + "--base-ref", + "origin/flag-base", + "--commit", + "flag: {slug}", + ], + ) + assert result.exit_code == 0 + mock_create.assert_called_once_with( + "Feature/Foo_Bar", "origin/flag-base", "T", True, "flag: {slug}", None, False + ) + mock_load.assert_not_called() + + def test_create_publish_config_template_beats_domain_default( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``topics.publish_commit`` wins over the domain default template.""" + monkeypatch.chdir(tmp_path) + _write_config( + tmp_path, + "language: python\ntopics:\n base_ref: origin/config-base\n publish_commit: 'config: {slug}'\n", + ) + + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "T"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with("X", "origin/config-base", "T", True, "config: {slug}", None, False) + + def test_create_publish_no_template_anywhere_passes_none( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No --commit and no topics.publish_commit: the template is None — the domain default.""" + monkeypatch.chdir(tmp_path) + _write_config(tmp_path, "language: python\ntopics:\n base_ref: origin/config-base\n") + + with mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create: + result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "T"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with("X", "origin/config-base", "T", True, None, None, False) + + def test_create_publish_flag_template_with_config_base( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A config base with a flag template — the flag template wins.""" + monkeypatch.chdir(tmp_path) + _write_config(tmp_path, "language: python\ntopics:\n base_ref: origin/config-base\n") + + with ( + mock.patch.object( + _topics_module, "load_project_config", wraps=_topics_module.load_project_config + ) as mock_load, + mock.patch.object(_topics_module, "create_topic", return_value="line") as mock_create, + ): + result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "T", "--commit", "flag: {slug}"]) + assert result.exit_code == 0 + mock_create.assert_called_once_with("X", "origin/config-base", "T", True, "flag: {slug}", None, False) + # The base flag is absent, so the config is read for it. + mock_load.assert_called_once_with() + + def test_create_publish_delegation(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The publish path delegates through create_topic with publish=True.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object( + _topics_module, + "create_topic", + return_value="Created branch X and published topic 2026/x", + ) as mock_create: + result = CliRunner().invoke(topics, ["create", "X", "--publish", "-t", "T", "--base-ref", "origin/main"]) + assert result.exit_code == 0 + assert mock_create.call_args == mock.call("X", "origin/main", "T", True, None, None, False) + assert result.output == "Created branch X and published topic 2026/x\n" + + def test_create_invalid_config_surfaces_its_own_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A malformed topics section surfaces the loader's error, not a 'no base' guess.""" + monkeypatch.chdir(tmp_path) + _write_config(tmp_path, "language: python\ntopics: 5\n") + + with mock.patch.object(_topics_module, "create_topic") as mock_create: + result = CliRunner().invoke(topics, ["create", "X", "--from-current"]) + assert result.exit_code == 1 + assert "'topics' must be a mapping in .goga/config.yml" in result.stderr + mock_create.assert_not_called() + + def test_create_unreadable_config_surfaces_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An unreadable configuration file (a directory in its place) + surfaces one clean error — not a raw IsADirectoryError traceback, and + not the 'no base' guess either. + """ + monkeypatch.chdir(tmp_path) + (tmp_path / ".goga").mkdir() + (tmp_path / ".goga" / "config.yml").mkdir() + + with mock.patch.object(_topics_module, "create_topic") as mock_create: + result = CliRunner().invoke(topics, ["create", "X", "--from-current"]) + assert result.exit_code == 1 + assert "Is a directory" in result.stderr + assert not isinstance(result.exception, IsADirectoryError) + mock_create.assert_not_called() + + +class TestTopicsSwitchTodo: + def test_switch_todo_flag_forwarded(self) -> None: + """--todo forwards the flag verbatim to switch_topic.""" + with mock.patch.object( + _topics_module, + "switch_topic", + return_value="Switched to branch feature-foo", + ) as mock_switch: + result = CliRunner().invoke(topics, ["switch", "feature-foo", "--todo"]) + assert result.exit_code == 0 + mock_switch.assert_called_once_with("feature-foo", True, None) + assert result.output.splitlines() == ["Switched to branch feature-foo"] + + def test_switch_todo_with_scoped_year(self) -> None: + """--todo and --year travel together to the domain.""" + with mock.patch.object(_topics_module, "switch_topic", return_value="line") as mock_switch: + result = CliRunner().invoke(topics, ["--year", "2025", "switch", "feature-foo", "--todo"]) + assert result.exit_code == 0 + mock_switch.assert_called_once_with("feature-foo", True, "2025") + + +class TestTopicsDelete: + def test_delete_confirmed_delegates_and_echoes(self) -> None: + """A confirmed list prints the pairs, asks once, delegates with a list, echoes the line.""" + targets = [ + DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True), + DeleteTarget(topic="release-1-3-0", branch=None, remote=None, has_dir=True), + ] + + with ( + mock.patch.object(click, "confirm", return_value=True) as mock_confirm, + mock.patch.object(_topics_module, "resolve_delete_targets", return_value=targets) as mock_resolve, + mock.patch.object( + _topics_module, + "delete_topics", + return_value="Deleted 2 topic(s) of 2026: feature-foo, release-1-3-0", + ) as mock_delete, + ): + result = CliRunner().invoke(topics, ["delete", "feature-foo", "release-1-3-0"], input=_TtyStdin()) + assert result.exit_code == 0 + # The click nargs=-1 tuple becomes a list at the boundary. + mock_resolve.assert_called_once_with(["feature-foo", "release-1-3-0"], None) + # One confirmation for the whole list — never per topic. + mock_confirm.assert_called_once_with("Delete 2 topic(s)?") + mock_delete.assert_called_once_with(targets, None) + assert "feature-foo -> feature-foo" in result.output + assert "release-1-3-0 -> (directory only)" in result.output + assert "Deleted 2 topic(s) of 2026: feature-foo, release-1-3-0" in result.output + + def test_delete_declined_confirmation_exits_zero(self) -> None: + """A declined confirmation exits 0 with nothing deleted.""" + targets = [DeleteTarget(topic="feature-foo", branch="feature-foo", remote=None, has_dir=True)] + + with ( + mock.patch.object(click, "confirm", return_value=False) as mock_confirm, + mock.patch.object(_topics_module, "resolve_delete_targets", return_value=targets) as mock_resolve, + mock.patch.object(_topics_module, "delete_topics") as mock_delete, + ): + result = CliRunner().invoke(topics, ["delete", "feature-foo"], input=_TtyStdin()) + assert result.exit_code == 0 + mock_confirm.assert_called_once_with("Delete 1 topic(s)?") + mock_resolve.assert_called_once_with(["feature-foo"], None) + mock_delete.assert_not_called() + assert "Deleted" not in result.output + + def test_delete_requires_terminal_without_yes(self) -> None: + """A non-TTY without --yes is a clean error — after the read-only resolution.""" + targets = [DeleteTarget(topic="feature-foo", branch="feature-foo", remote=None, has_dir=True)] + + with ( + mock.patch.object(click, "confirm") as mock_confirm, + mock.patch.object(_topics_module, "resolve_delete_targets", return_value=targets) as mock_resolve, + mock.patch.object(_topics_module, "delete_topics") as mock_delete, + ): + result = CliRunner().invoke(topics, ["delete", "feature-foo"]) + assert result.exit_code == 1 + assert "interactive terminal" in result.stderr + assert "Traceback" not in result.stderr + mock_resolve.assert_called_once_with(["feature-foo"], None) + mock_confirm.assert_not_called() + mock_delete.assert_not_called() + + def test_delete_yes_short_form_scoped_to_subcommand(self) -> None: + """``topics -y 2025 delete -y x``: the group -y binds the year, the subcommand -y the skip.""" + targets = [DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True)] + + with ( + mock.patch.object(click, "confirm") as mock_confirm, + mock.patch.object(_topics_module, "resolve_delete_targets", return_value=targets) as mock_resolve, + mock.patch.object( + _topics_module, "delete_topics", return_value="Deleted 1 topic(s) of 2025: feature-foo" + ) as mock_delete, + ): + result = CliRunner().invoke(topics, ["-y", "2025", "delete", "-y", "feature-foo"]) + assert result.exit_code == 0 + mock_resolve.assert_called_once_with(["feature-foo"], "2025") + mock_confirm.assert_not_called() + mock_delete.assert_called_once_with(targets, "2025") + assert "Deleted 1 topic(s) of 2025: feature-foo" in result.output + + def test_delete_resolution_error_surfaces_clean(self) -> None: + """A resolution error is clean and deletes nothing.""" + with ( + mock.patch.object( + _topics_module, + "resolve_delete_targets", + side_effect=click.ClickException("no topic matches 'nope' — run 'goga topics board'"), + ) as mock_resolve, + mock.patch.object(_topics_module, "delete_topics") as mock_delete, + ): + result = CliRunner().invoke(topics, ["delete", "nope"]) + assert result.exit_code == 1 + assert "no topic matches" in result.stderr + assert "Traceback" not in result.stderr + mock_resolve.assert_called_once_with(["nope"], None) + mock_delete.assert_not_called() diff --git a/tests/config/test_config.py b/tests/config/test_config.py index bbcc454c..c551a102 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -11,6 +11,7 @@ LintConfig, PipelineConfig, ProjectConfig, + ReviewExecutorConfig, TaskExecutorConfig, ) from goga.config.project.config import DepConfig @@ -134,10 +135,18 @@ def test_review_executor_declared_fields(self): from goga.config import ReviewExecutorConfig names = [f.name for f in dataclasses.fields(ReviewExecutorConfig)] - assert names == ["skip", "agent", "roles", "env"] + assert names == ["skip", "agent", "roles", "env", "base_ref", "patience"] assert ReviewExecutorConfig.__dataclass_fields__["env"].type == dict[str, str] assert ReviewExecutorConfig(skip=None, agent=None, roles=None).env == {} + def test_review_executor_config_declares_base_ref_and_patience_fields(self): + """ReviewExecutorConfig carries the review-scoped base_ref/patience fields and + BuildConfig no longer declares the relocated review_patience.""" + from goga.config import ReviewExecutorConfig + + assert {"base_ref", "patience"} <= set(ReviewExecutorConfig.__dataclass_fields__) + assert "review_patience" not in BuildConfig.__dataclass_fields__ + def test_has_worktree_field(self): assert "worktree" in BuildConfig.__dataclass_fields__ @@ -156,8 +165,9 @@ def test_has_wait_field(self): def test_has_max_iterations_field(self): assert "max_iterations" in BuildConfig.__dataclass_fields__ - def test_has_review_patience_field(self): - assert "review_patience" in BuildConfig.__dataclass_fields__ + def test_review_patience_field_removed(self): + """The relocated review_patience is gone from BuildConfig.""" + assert "review_patience" not in BuildConfig.__dataclass_fields__ def test_has_prompts_dir_field(self): assert "prompts_dir" in BuildConfig.__dataclass_fields__ @@ -417,7 +427,7 @@ def test_all_none_optional_fields(self): assert bc.idle_timeout is None assert bc.wait is None assert bc.max_iterations is None - assert bc.review_patience is None + assert not hasattr(bc, "review_patience") assert bc.prompts_dir is None assert bc.agents_dir is None assert bc.codex_review is None @@ -427,6 +437,7 @@ def test_all_none_optional_fields(self): def test_all_fields_populated(self): te = TaskExecutorConfig(agent="gemini", env={"X": "1"}) + review = ReviewExecutorConfig(agent="codex", base_ref="origin/1.2.x", patience=3) bc = BuildConfig( task_executor=te, worktree=True, @@ -435,10 +446,10 @@ def test_all_fields_populated(self): idle_timeout="1h", wait="5m", max_iterations=10, - review_patience=3, prompts_dir="/custom/prompts", agents_dir="/custom/agents", codex_review=True, + review_executor=review, ) assert bc.task_executor.agent == "gemini" assert bc.task_executor.env == {"X": "1"} @@ -448,7 +459,8 @@ def test_all_fields_populated(self): assert bc.idle_timeout == "1h" assert bc.wait == "5m" assert bc.max_iterations == 10 - assert bc.review_patience == 3 + assert bc.review_executor.patience == 3 + assert bc.review_executor.base_ref == "origin/1.2.x" assert bc.prompts_dir == "/custom/prompts" assert bc.agents_dir == "/custom/agents" assert bc.codex_review is True @@ -648,13 +660,14 @@ def test_lintconfig_kw_only_enforced(self): assert all(f.kw_only for f in dataclasses.fields(LintConfig)) def test_projectconfig_has_lint_field_default_none(self): - """ProjectConfig.lint defaults to None and is the last kw_only field.""" + """ProjectConfig.lint defaults to None and keeps its trailing append (topics follows it).""" cfg = ProjectConfig(lang="python", image=None, dockerfile=None, build=None, pipeline=None) assert cfg.lint is None assert "lint" in ProjectConfig.__dataclass_fields__ field_names = list(ProjectConfig.__dataclass_fields__.keys()) - assert field_names[-1] == "lint" + assert field_names[-2] == "lint" + assert field_names[-1] == "topics" def test_projectconfig_lint_accepts_lintconfig(self): te = TaskExecutorConfig(agent="claude") diff --git a/tests/config/test_integration.py b/tests/config/test_integration.py index b08f0b46..ff4753b4 100644 --- a/tests/config/test_integration.py +++ b/tests/config/test_integration.py @@ -29,10 +29,12 @@ idle_timeout: "2h" wait: "10m" max_iterations: 20 - review_patience: 5 prompts_dir: "/etc/goga/prompts" agents_dir: "/etc/goga/agents" codex_review: false + review_executor: + base_ref: origin/1.2.x + patience: 5 commands: build: cargo build --release test: cargo test @@ -91,7 +93,8 @@ def test_full_object_graph_from_yaml(self, tmp_path, monkeypatch): assert config.build.idle_timeout == "2h" assert config.build.wait == "10m" assert config.build.max_iterations == 20 - assert config.build.review_patience == 5 + assert config.build.review_executor.patience == 5 + assert config.build.review_executor.base_ref == "origin/1.2.x" assert config.build.prompts_dir == "/etc/goga/prompts" assert config.build.agents_dir == "/etc/goga/agents" assert config.build.codex_review is False @@ -127,7 +130,7 @@ def test_minimal_yaml_produces_defaults(self, tmp_path, monkeypatch): assert config.build.idle_timeout is None assert config.build.wait is None assert config.build.max_iterations is None - assert config.build.review_patience is None + assert config.build.review_executor is None assert config.build.prompts_dir is None assert config.build.agents_dir is None assert config.build.codex_review is None diff --git a/tests/config/test_loader.py b/tests/config/test_loader.py index c8e8fe0a..eca715e6 100644 --- a/tests/config/test_loader.py +++ b/tests/config/test_loader.py @@ -2,6 +2,7 @@ import dataclasses import inspect +import re import goga.config as goga_config_mod import pytest @@ -12,6 +13,7 @@ PipelineConfig, ProjectConfig, TaskExecutorConfig, + TopicsConfig, load_project_config, ) from goga.config.project.config import DepConfig @@ -21,6 +23,8 @@ _parse_dockerfile, _parse_lint, _parse_tools, + _parse_topics, + _parse_topics_field, _parse_usages, _validate_usages_root, ) @@ -75,10 +79,12 @@ def _write_goga_yml(path, content: str): idle_timeout: "1h" wait: "5m" max_iterations: 10 - review_patience: 3 prompts_dir: "/custom/prompts" agents_dir: "/custom/agents" codex_review: true + review_executor: + base_ref: origin/1.2.x + patience: 3 """ HAPPY_YAML = """\ @@ -161,7 +167,8 @@ def test_load_config_full_yaml(self, goga_project): assert config.build.idle_timeout == "1h" assert config.build.wait == "5m" assert config.build.max_iterations == 10 - assert config.build.review_patience == 3 + assert config.build.review_executor.base_ref == "origin/1.2.x" + assert config.build.review_executor.patience == 3 assert config.build.prompts_dir == "/custom/prompts" assert config.build.agents_dir == "/custom/agents" assert config.build.codex_review is True @@ -2857,9 +2864,10 @@ def test_projectconfig_has_lint_field(self): assert "lint" in field_names def test_projectconfig_lint_is_last_field(self): - """lint is the last declared field of ProjectConfig (backward-compatible append).""" + """lint is the last field before the trailing topics (backward-compatible append).""" field_names = [f.name for f in dataclasses.fields(ProjectConfig)] - assert field_names[-1] == "lint" + assert field_names[-2] == "lint" + assert field_names[-1] == "topics" def test_projectconfig_lint_annotation_optional_lintconfig(self): """lint field type is LintConfig | None.""" @@ -3102,17 +3110,19 @@ def test_review_executor_config_importable_from_project_cell(self): assert project_mod.ReviewExecutorConfig is ReviewExecutorConfig def test_review_executor_config_is_frozen_kw_only_dataclass(self): - """ReviewExecutorConfig is a frozen kw_only dataclass with four fields.""" + """ReviewExecutorConfig is a frozen kw_only dataclass with six fields.""" from goga.config.project.config import ReviewExecutorConfig assert dataclasses.is_dataclass(ReviewExecutorConfig) params = {f.name: f for f in dataclasses.fields(ReviewExecutorConfig)} - assert set(params) == {"skip", "agent", "roles", "env"} + assert set(params) == {"skip", "agent", "roles", "env", "base_ref", "patience"} assert params["skip"].default is None assert params["agent"].default is None assert params["roles"].default is None assert params["env"].default is dataclasses.MISSING assert params["env"].default_factory is dict + assert params["base_ref"].default is None + assert params["patience"].default is None def test_review_executor_config_reexport_from_facade_alive(self): """goga.config re-exports the same class object as the project cell.""" @@ -3298,11 +3308,12 @@ def test_loader_parses_review_executor_env_mapping(self, goga_project): ) def test_review_executor_config_declared_fields_include_env(self): - """Declared fields are skip, agent, roles, env; env is a factory-defaulted dict[str, str].""" + """Declared fields are skip, agent, roles, env, base_ref, patience; env is + a factory-defaulted dict[str, str].""" from goga.config.project.config import ReviewExecutorConfig names = [f.name for f in dataclasses.fields(ReviewExecutorConfig)] - assert names == ["skip", "agent", "roles", "env"] + assert names == ["skip", "agent", "roles", "env", "base_ref", "patience"] assert ReviewExecutorConfig.__dataclass_fields__["env"].type == dict[str, str] env_field = {f.name: f for f in dataclasses.fields(ReviewExecutorConfig)}["env"] assert env_field.default is dataclasses.MISSING @@ -3374,3 +3385,359 @@ def test_loader_review_executor_env_absent_null_empty_all_empty_dict(self, goga_ config = load_project_config() assert config.build.review_executor is not None, env_id assert config.build.review_executor.env == {}, env_id + + def test_review_executor_base_ref_parsed_verbatim(self, goga_project): + """review_executor.base_ref string is stored verbatim as a str.""" + _write_goga_yml( + goga_project, + """\ +language: python +build: + task_executor: + agent: claude + review_executor: + agent: claude + base_ref: origin/1.2.x +""", + ) + config = load_project_config() + assert config.build.review_executor.base_ref == "origin/1.2.x" + assert isinstance(config.build.review_executor.base_ref, str) + + def test_review_executor_base_ref_padded_stripped(self, goga_project): + """review_executor.base_ref with surrounding whitespace is stored stripped. + + Exact equality — an implementation that only nulls the whitespace-only + case without assigning the stripped value fails. + """ + _write_goga_yml( + goga_project, + """\ +language: python +build: + task_executor: + agent: claude + review_executor: + base_ref: " origin/1.2.x " +""", + ) + config = load_project_config() + assert config.build.review_executor.base_ref == "origin/1.2.x" + + def test_review_executor_patience_int_parsed(self, goga_project): + """review_executor.patience YAML int is stored verbatim as an int.""" + _write_goga_yml( + goga_project, + """\ +language: python +build: + task_executor: + agent: claude + review_executor: + patience: 3 +""", + ) + config = load_project_config() + assert config.build.review_executor.patience == 3 + assert isinstance(config.build.review_executor.patience, int) + + def test_review_executor_base_ref_non_string_raises(self, goga_project): + """review_executor.base_ref: 12 → ValueError with the exact contract message.""" + _write_goga_yml( + goga_project, + """\ +language: python +build: + task_executor: + agent: claude + review_executor: + base_ref: 12 +""", + ) + + with pytest.raises(ValueError, match=r"review_executor\.base_ref must be a string"): + load_project_config() + + @pytest.mark.parametrize( + "patience_snippet", + ['patience: "3"', "patience: 3.5"], + ids=["quoted-string", "float"], + ) + def test_review_executor_patience_non_int_raises(self, goga_project, patience_snippet): + """A non-int patience (str, float) raises ValueError with the exact message.""" + _write_goga_yml( + goga_project, + f"""\ +language: python +build: + task_executor: + agent: claude + review_executor: + {patience_snippet} +""", + ) + + with pytest.raises(ValueError, match=r"review_executor\.patience must be an int"): + load_project_config() + + def test_review_executor_patience_yaml_bool_rejected(self, goga_project): + """patience: true → ValueError — guards the bool-before-int check order.""" + _write_goga_yml( + goga_project, + """\ +language: python +build: + task_executor: + agent: claude + review_executor: + patience: true +""", + ) + + with pytest.raises(ValueError, match=r"review_executor\.patience must be an int"): + load_project_config() + + def test_legacy_build_review_patience_key_not_parsed(self, goga_project): + """A legacy build.review_patience key is silently ignored — no field, no error.""" + _write_goga_yml( + goga_project, + """\ +language: python +build: + task_executor: + agent: claude + review_patience: 5 +""", + ) + config = load_project_config() + assert not hasattr(config.build, "review_patience") + + @pytest.mark.parametrize( + "base_ref_snippet", + ["", "base_ref: null\n", 'base_ref: ""\n', 'base_ref: " "\n'], + ids=["absent", "yaml-null", "empty-string", "whitespace-only"], + ) + def test_review_executor_base_ref_unset_variants_resolve_none(self, goga_project, base_ref_snippet): + """Absent, YAML-null, empty and whitespace-only base_ref all resolve to None.""" + _write_goga_yml( + goga_project, + f"""\ +language: python +build: + task_executor: + agent: claude + review_executor: + agent: claude + {base_ref_snippet}""", + ) + config = load_project_config() + assert config.build.review_executor is not None + assert config.build.review_executor.base_ref is None + + @pytest.mark.parametrize( + "patience_snippet", + ["agent: claude\n", "agent: claude\n patience: null\n"], + ids=["absent", "yaml-null"], + ) + def test_review_executor_patience_unset_variants_resolve_none(self, goga_project, patience_snippet): + """Absent and YAML-null patience both resolve to None. + + The absent-section variant is pinned by test_loader_review_executor_absent_and_null.""" + _write_goga_yml( + goga_project, + f"""\ +language: python +build: + task_executor: + agent: claude + review_executor: + {patience_snippet}""", + ) + config = load_project_config() + assert config.build.review_executor is not None + assert config.build.review_executor.patience is None + + @pytest.mark.parametrize( + ("patience_literal", "patience_id"), + [("0", "zero"), ("-1", "negative")], + ) + def test_review_executor_patience_zero_and_negative_verbatim(self, goga_project, patience_literal, patience_id): + """patience 0 and -1 are stored verbatim — structural typing, no range check.""" + _write_goga_yml( + goga_project, + f"""\ +language: python +build: + task_executor: + agent: claude + review_executor: + patience: {patience_literal} +""", + ) + config = load_project_config() + assert config.build.review_executor.patience == int(patience_literal), patience_id + + +# --- Contract + logic tests for TopicsConfig + the topics section (loader step 10) --- + + +class TestParseTopicsContract: + def test_parse_topics_exists(self): + """_parse_topics is importable from goga.config.project.loader.""" + assert callable(_parse_topics) + + def test_parse_topics_signature(self): + """_parse_topics accepts a single dict parameter (parity with _parse_lint).""" + sig = inspect.signature(_parse_topics) + assert list(sig.parameters.keys()) == ["data"] + + def test_parse_topics_return_annotation(self): + """_parse_topics returns TopicsConfig | None.""" + ret = inspect.signature(_parse_topics).return_annotation + assert ret == TopicsConfig | None + + def test_parse_topics_field_signature(self): + """_parse_topics_field takes (value, key) positionally.""" + sig = inspect.signature(_parse_topics_field) + assert list(sig.parameters.keys()) == ["value", "key"] + + def test_projectconfig_topics_is_last_field(self): + """topics is the last declared field of ProjectConfig (backward-compatible append).""" + field_names = [f.name for f in dataclasses.fields(ProjectConfig)] + assert field_names[-1] == "topics" + + def test_projectconfig_topics_defaults_none(self): + """topics defaults to None (backward compatible — section absent).""" + assert {f.name: f for f in dataclasses.fields(ProjectConfig)}["topics"].default is None + + +class TestParseTopicsLogic: + def test_parse_topics_without_section_returns_none(self): + """No topics section → returns None.""" + assert _parse_topics({"language": "python"}) is None + + def test_parse_topics_null_section_returns_none(self): + """topics: null → returns None.""" + assert _parse_topics({"topics": None}) is None + + def test_parse_topics_empty_mapping_yields_instance(self): + """topics: {} → TopicsConfig(base_ref=None, publish_commit=None) — an instance, not None.""" + result = _parse_topics({"topics": {}}) + assert isinstance(result, TopicsConfig) + assert result == TopicsConfig(base_ref=None, publish_commit=None) + + def test_parse_topics_unknown_keys_are_ignored(self): + """Unknown keys inside the mapping are ignored (cell-wide stance).""" + result = _parse_topics({"topics": {"base_ref": "origin/main", "future_key": 5}}) + assert result == TopicsConfig(base_ref="origin/main", publish_commit=None) + + @pytest.mark.parametrize("bad_section", ["not-a-mapping", 5, ["a", "b"]]) + def test_parse_topics_rejects_non_mapping_section(self, bad_section): + """topics section that is not a mapping (str/int/list) → ValueError.""" + with pytest.raises(ValueError, match=r"'topics' must be a mapping"): + _parse_topics({"topics": bad_section}) + + def test_parse_topics_field_null_returns_none(self): + """A YAML-null field value → None.""" + assert _parse_topics_field(None, "topics.base_ref") is None + + def test_parse_topics_field_strips_to_none(self): + """An empty or whitespace-only string → None (the loader's empty-to-None rule).""" + assert _parse_topics_field("", "topics.base_ref") is None + assert _parse_topics_field(" ", "topics.publish_commit") is None + + def test_parse_topics_field_strips_surrounding_whitespace(self): + """Surrounding whitespace is stripped; the remainder is stored verbatim.""" + assert _parse_topics_field(" origin/main ", "topics.base_ref") == "origin/main" + + def test_parse_topics_field_rejects_non_string(self): + """A present non-string field is a structural type error.""" + with pytest.raises(ValueError, match=r"topics\.base_ref must be a string"): + _parse_topics_field(5, "topics.base_ref") + + +class TestLoadConfigTopics: + def test_topics_section_absent_yields_none(self, goga_project): + """Config with only language → cfg.topics is None, lang parsed, build None.""" + _write_goga_yml(goga_project, "language: python\n") + config = load_project_config() + assert config.topics is None + assert config.lang == "python" + assert config.build is None + + def test_topics_section_null_yields_none(self, goga_project): + """topics: null → cfg.topics is None.""" + _write_goga_yml(goga_project, "language: python\ntopics: null\n") + config = load_project_config() + assert config.topics is None + + def test_topics_section_parsed_verbatim(self, goga_project): + """Both fields stored verbatim — {slug} braces survive, no grammar check.""" + _write_goga_yml( + goga_project, + 'language: python\ntopics:\n base_ref: origin/release-1.3\n publish_commit: "chore: {slug}"\n', + ) + config = load_project_config() + assert config.topics == TopicsConfig(base_ref="origin/release-1.3", publish_commit="chore: {slug}") + + def test_topics_section_not_mapping_raises_value_error(self, goga_project): + """topics: 5 → ValueError with the exact message (not AttributeError).""" + _write_goga_yml(goga_project, "language: python\ntopics: 5\n") + + with pytest.raises(ValueError, match=r"^'topics' must be a mapping in \.goga/config\.yml$"): + load_project_config() + + @pytest.mark.parametrize( + ("bad_yaml", "message"), + [ + ("topics:\n base_ref: 5\n", "topics.base_ref must be a string in .goga/config.yml"), + ("topics:\n publish_commit:\n - 1\n", "topics.publish_commit must be a string in .goga/config.yml"), + ], + ) + def test_topics_field_not_string_raises_value_error(self, goga_project, bad_yaml, message): + """A non-string topics field is a structural type error with the dotted key.""" + _write_goga_yml(goga_project, f"language: python\n{bad_yaml}") + + with pytest.raises(ValueError, match=f"^{re.escape(message)}$"): + load_project_config() + + @pytest.mark.parametrize( + ("base_ref_yaml", "field_id"), + [ + ("base_ref: null", "yaml-null"), + ('base_ref: " "', "whitespace"), + ('base_ref: ""', "empty"), + ], + ) + def test_topics_base_ref_unset_forms_normalize_to_none(self, goga_project, base_ref_yaml, field_id): + """base_ref absent/YAML-null/empty/whitespace → None; publish_commit stays verbatim.""" + _write_goga_yml( + goga_project, + f'language: python\ntopics:\n {base_ref_yaml}\n publish_commit: "chore: {{slug}}"\n', + ) + config = load_project_config() + assert config.topics is not None + assert config.topics.base_ref is None, field_id + assert config.topics.publish_commit == "chore: {slug}" + + def test_topics_section_empty_mapping_yields_topics_config(self, goga_project): + """topics: {} → an instance with both fields None — "explicit absence" semantics.""" + _write_goga_yml(goga_project, "language: python\ntopics: {}\n") + config = load_project_config() + assert config.topics is not None + assert isinstance(config.topics, TopicsConfig) + assert config.topics == TopicsConfig(base_ref=None, publish_commit=None) + + def test_topics_section_alongside_other_sections(self, goga_project): + """topics coexists with the full schema; sibling sections stay intact.""" + _write_goga_yml( + goga_project, + "language: python\nimage: qarium/foo:1.0\npipeline:\n agent: claude\n" + "build:\n task_executor:\n agent: claude\nlint:\n ignore:\n - .venv/\n" + "topics:\n base_ref: origin/main\n", + ) + config = load_project_config() + assert config.topics == TopicsConfig(base_ref="origin/main", publish_commit=None) + assert config.lint is not None + assert config.lint.ignore == [".venv/"] + assert config.pipeline.agent == "claude" diff --git a/tests/config/test_project_cell_contract.py b/tests/config/test_project_cell_contract.py index 16e81c73..3a72cc93 100644 --- a/tests/config/test_project_cell_contract.py +++ b/tests/config/test_project_cell_contract.py @@ -1,7 +1,9 @@ # tests/config/test_project_cell_contract.py — contract + logic tests for the relocated/renamed project cell +import dataclasses import inspect +import goga.config as goga_config_mod import goga.config.project as project_mod import pytest from goga.config.project import ( @@ -9,9 +11,12 @@ CodemanifestConfig, PipelineConfig, ProjectConfig, + TopicsConfig, load_project_config, ) +from tests.conftest import is_kw_only_dataclass + # --- Helpers --- @@ -78,6 +83,74 @@ def test_load_project_config_returns_project_config_instance(self, goga_project) assert type(result) is project_mod.ProjectConfig +class TestTopicsConfigContract: + def test_topics_config_on_both_facades(self): + """TopicsConfig is importable from goga.config.project AND goga.config.""" + from goga.config import TopicsConfig + + assert project_mod.TopicsConfig is TopicsConfig + assert goga_config_mod.TopicsConfig is TopicsConfig + assert "TopicsConfig" in project_mod.__all__ + assert "TopicsConfig" in goga_config_mod.__all__ + + def test_topics_config_is_frozen_kw_only_dataclass(self): + """TopicsConfig is an immutable kw_only dataclass per `convention`.""" + params = TopicsConfig.__dataclass_params__ + assert params.frozen is True + assert is_kw_only_dataclass(TopicsConfig) + + def test_topics_config_declares_exactly_the_two_fields(self): + """The declared field set is exactly {base_ref, publish_commit}.""" + assert {f.name for f in dataclasses.fields(TopicsConfig)} == {"base_ref", "publish_commit"} + + def test_topics_config_fields_are_kw_only_without_defaults(self): + """Both fields are keyword-only and carry no defaults — the loader always passes both.""" + for field in dataclasses.fields(TopicsConfig): + assert field.kw_only is True + assert field.default is dataclasses.MISSING + assert field.default_factory is dataclasses.MISSING + + def test_topics_config_optional_union_annotations(self): + """Both fields are typed str | None ("explicit absence" semantics).""" + fields = {f.name: f for f in dataclasses.fields(TopicsConfig)} + assert fields["base_ref"].type == str | None + assert fields["publish_commit"].type == str | None + + def test_topics_config_stores_fields_verbatim(self): + """Pure construction stores both values verbatim — no normalization here.""" + config = TopicsConfig(base_ref="origin/release-1.3", publish_commit="chore: {slug}") + assert config.base_ref == "origin/release-1.3" + assert config.publish_commit == "chore: {slug}" + + def test_project_config_gains_trailing_topics_field(self): + """ProjectConfig declares `topics` as its LAST field, defaulting to None.""" + fields = dataclasses.fields(ProjectConfig) + assert fields[-1].name == "topics" + assert fields[-1].default is None + + def test_project_config_topics_annotation_optional(self): + """The topics field type is TopicsConfig | None.""" + topics_field = {f.name: f for f in dataclasses.fields(ProjectConfig)}["topics"] + assert topics_field.type == TopicsConfig | None + + def test_load_project_config_signature_unchanged(self): + """load_project_config still accepts no arguments.""" + assert list(inspect.signature(load_project_config).parameters.keys()) == [] + + def test_project_config_existing_callers_stay_valid(self): + """ProjectConfig(...) omitting topics=/usages=/lint= stays constructible; topics is None.""" + config = ProjectConfig( + lang="python", + image=None, + dockerfile=None, + build=None, + pipeline=None, + commands={}, + ) + assert config.topics is None + assert config.lang == "python" + + # --- Logic tests (relocated loader exercised end-to-end) --- diff --git a/tests/conftest.py b/tests/conftest.py index 560df71b..37d25c53 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import dataclasses import importlib import itertools import os @@ -50,6 +51,23 @@ def cwd(path): os.chdir(str(original)) +def is_kw_only_dataclass(cls: type) -> bool: + """Report whether every field of ``cls`` is keyword-only. + + Portable across Python 3.10 - 3.14: the class-level + ``_DataclassParams.kw_only`` attribute exists only from Python 3.12, so + ``cls.__dataclass_params__.kw_only`` is unusable on 3.10/3.11, while + ``dataclasses.fields()`` exposes the same fact on every version. + + Args: + cls: The dataclass to inspect. + + Returns: + True when every field of ``cls`` is keyword-only. + """ + return all(field.kw_only for field in dataclasses.fields(cls)) + + # --- shared cell-level usages fixtures (used by tests/usages and tests/commands) --- _CONFIG_HEADER = [ diff --git a/tests/history/__init__.py b/tests/history/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/history/git/__init__.py b/tests/history/git/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/history/git/test_branch.py b/tests/history/git/test_branch.py new file mode 100644 index 00000000..7ef97673 --- /dev/null +++ b/tests/history/git/test_branch.py @@ -0,0 +1,158 @@ +"""Contract and logic tests for the routine declared in +``goga/history/git/CODEMANIFEST`` with ``location: branch.py``: + +- ``resolve_current_branch_name() -> str | None`` — the raw git branch reader + with the three documented None modes (detached HEAD, missing git binary, + non-repository) + +The cell's second module — the branch inventory of ``refs.py`` — is covered +by ``tests/history/git/test_refs.py``. + +Git is mocked at the subprocess boundary per the ``git`` practice — +``mock.patch.object(branch_module.subprocess, "run")`` — never as a git double. +""" + +from __future__ import annotations + +import inspect +import subprocess +import typing +from unittest import mock + +import pytest +from goga.history.git import branch as branch_module +from goga.history.git import resolve_current_branch_name + +# --- Git subprocess mocking helpers (the process boundary only) --- + + +class _GitResult: + """Minimal stand-in for a ``subprocess.CompletedProcess``.""" + + def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = "") -> None: + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def _git_run_dispatch( + show_current: object = _GitResult(stdout="main\n"), + show_ref: object = subprocess.CalledProcessError(1, "git"), + for_each_ref: object = _GitResult(stdout=""), + switch: object = _GitResult(returncode=0), +) -> mock.Mock: + """Build a ``subprocess.run`` mock dispatching per git subcommand. + + ``show_current`` / ``show_ref`` / ``for_each_ref`` / ``switch`` are either a + ``_GitResult`` (returned) or an exception instance/class (raised). Any of + them may instead be a LIST of such outcomes consumed in call order + (exhausted → AssertionError) — for re-ask sequences where the same command + must answer differently per iteration. + """ + outcomes = { + ("branch", "--show-current"): show_current, + ("show-ref",): show_ref, + ("for-each-ref",): for_each_ref, + ("switch",): switch, + } + queues = {key: (list(value) if isinstance(value, list) else None) for key, value in outcomes.items()} + + def _run(argv: list[str], **_kwargs: object) -> _GitResult: + for key, default_outcome in outcomes.items(): + if tuple(argv[1 : 1 + len(key)]) == key: + outcome = default_outcome + if queues[key] is not None: + if not queues[key]: + raise AssertionError(f"unexpected repeat of git argv in test: {argv!r}") + outcome = queues[key].pop(0) + if isinstance(outcome, BaseException) or ( + isinstance(outcome, type) and issubclass(outcome, BaseException) + ): + raise outcome + return outcome + raise AssertionError(f"unexpected git argv in test: {argv!r}") + + return mock.Mock(side_effect=_run) + + +# --- Contract tests --- + + +class TestGitBranchContract: + def test_routine_is_importable_from_facade_and_callable(self) -> None: + """The routine is importable from ``goga.history.git`` and callable.""" + assert callable(resolve_current_branch_name) + assert branch_module.resolve_current_branch_name is resolve_current_branch_name + + def test_facade_all_lists_the_routine(self) -> None: + """The cell facade exports the reader and the inventory, sorted.""" + import goga.history.git + + assert goga.history.git.__all__ == [ + "BranchRef", + "list_branch_refs", + "resolve_current_branch_name", + ] + + def test_resolve_current_branch_name_signature(self) -> None: + """``resolve_current_branch_name() -> str | None`` — no parameters.""" + signature = inspect.signature(resolve_current_branch_name) + assert list(signature.parameters) == [] + hints = typing.get_type_hints(resolve_current_branch_name) + assert hints == {"return": str | None} + + +# --- Logic tests --- + + +class TestResolveCurrentBranchName: + def test_returns_raw_branch_name_stripped(self) -> None: + """A non-empty git answer is returned stripped and unmodified.""" + run_mock = _git_run_dispatch(show_current=_GitResult(stdout=" release/1.3.0\n")) + + with mock.patch.object(branch_module.subprocess, "run", run_mock): + assert resolve_current_branch_name() == "release/1.3.0" + + def test_asks_git_show_current_without_terminal_prompts(self) -> None: + """The invocation follows the ``git`` practice: argv and prompt-free env.""" + run_mock = _git_run_dispatch() + + with mock.patch.object(branch_module.subprocess, "run", run_mock): + resolve_current_branch_name() + + call = run_mock.call_args + assert call.args[0] == ["git", "branch", "--show-current"] + assert call.kwargs["check"] is True + assert call.kwargs["capture_output"] is True + assert call.kwargs["text"] is True + assert call.kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0" + + def test_detached_head_returns_none(self) -> None: + """An empty git answer (detached HEAD) is a documented None mode.""" + run_mock = _git_run_dispatch(show_current=_GitResult(stdout="")) + + with mock.patch.object(branch_module.subprocess, "run", run_mock): + assert resolve_current_branch_name() is None + + def test_non_repository_returns_none(self) -> None: + """A non-zero git exit (a non-repository) is a documented None mode.""" + run_mock = _git_run_dispatch( + show_current=subprocess.CalledProcessError(128, "git", stderr="fatal: not a git repository"), + ) + + with mock.patch.object(branch_module.subprocess, "run", run_mock): + assert resolve_current_branch_name() is None + + def test_missing_git_binary_returns_none(self) -> None: + """A missing git binary is a documented None mode.""" + run_mock = mock.Mock(side_effect=FileNotFoundError("git")) + + with mock.patch.object(branch_module.subprocess, "run", run_mock): + assert resolve_current_branch_name() is None + + def test_unexpected_os_failure_propagates(self) -> None: + """An unexpected ``PermissionError`` is not swallowed by the None modes.""" + run_mock = mock.Mock(side_effect=PermissionError("git")) + + with mock.patch.object(branch_module.subprocess, "run", run_mock), pytest.raises(PermissionError): + resolve_current_branch_name() diff --git a/tests/history/git/test_refs.py b/tests/history/git/test_refs.py new file mode 100644 index 00000000..9172d968 --- /dev/null +++ b/tests/history/git/test_refs.py @@ -0,0 +1,187 @@ +"""Contract and logic tests for the entities declared in +``goga/history/git/CODEMANIFEST`` with ``location: refs.py``: + +- ``BranchRef(name, remote)`` — one branch ref of the repository inventory +- ``list_branch_refs()`` — the read-only enumerator merging local branches + and remote-tracking refs into one sorted inventory + +The module mirrors ``goga/topics/git/refs.py`` (the cells are unlinked by +design — no imports between them); the mirror equivalence is pinned by +``test_history_git_inventory_matches_topics_git``. The subprocess call is +mocked at the import point per the ``convention`` practice — no git binary +and no repository are touched. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import os +import subprocess +import typing +from collections.abc import Callable +from unittest import mock + +import pytest +from goga.history.git import BranchRef, list_branch_refs + + +def _git_answer(stdout: str) -> subprocess.CompletedProcess[str]: + """A successful ``for-each-ref`` invocation answering ``stdout``.""" + return subprocess.CompletedProcess(args=["git"], returncode=0, stdout=stdout, stderr="") + + +def _answering_run(heads: str = "", remotes: str = "") -> Callable[..., subprocess.CompletedProcess[str]]: + """A ``subprocess.run`` mock answering by the requested ref prefix.""" + + def run(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + outputs = {"refs/heads": heads, "refs/remotes": remotes} + return _git_answer(outputs[command[-1]]) + + return run + + +# --- Contract tests --- + + +class TestRefsContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """``BranchRef`` and ``list_branch_refs`` live on the cell facade.""" + import goga.history.git as cell + + assert cell.BranchRef is BranchRef + assert cell.list_branch_refs is list_branch_refs + assert "BranchRef" in cell.__all__ + assert "list_branch_refs" in cell.__all__ + + def test_facade_all_lists_the_three_declared_names(self) -> None: + """The facade exports the branch reader and the branch inventory.""" + import goga.history.git + + assert goga.history.git.__all__ == [ + "BranchRef", + "list_branch_refs", + "resolve_current_branch_name", + ] + + def test_branch_ref_is_a_frozen_kw_only_dataclass(self) -> None: + """``BranchRef(name=..., remote=...)`` — frozen, keyword-only.""" + ref = BranchRef(name="feat/a", remote=False) + + assert ref.name == "feat/a" + assert ref.remote is False + + with pytest.raises(TypeError): + BranchRef("feat/a", False) # type: ignore[misc] + with pytest.raises(dataclasses.FrozenInstanceError): + ref.name = "renamed" # type: ignore[misc] + + def test_branch_ref_declares_name_and_remote_only(self) -> None: + """The record carries exactly the two declared fields.""" + fields = {field.name for field in dataclasses.fields(BranchRef)} + assert fields == {"name", "remote"} + + def test_list_branch_refs_signature(self) -> None: + """``list_branch_refs() -> list[BranchRef]`` — no parameters.""" + signature = inspect.signature(list_branch_refs) + assert list(signature.parameters) == [] + hints = typing.get_type_hints(list_branch_refs) + assert hints == {"return": list[BranchRef]} + + def test_git_invocations_follow_the_git_practice(self) -> None: + """Two ``for-each-ref`` calls — check/capture/text and a muted prompt.""" + run = mock.Mock(side_effect=_answering_run()) + + with mock.patch("goga.history.git.refs.subprocess.run", run): + list_branch_refs() + + assert run.call_count == 2 + for call in run.call_args_list: + command = call.args[0] + assert command[:2] == ["git", "for-each-ref"] + assert "--format=%(refname:short)" in command + assert call.kwargs["check"] is True + assert call.kwargs["capture_output"] is True + assert call.kwargs["text"] is True + assert call.kwargs["env"] == {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + prefixes = [call.args[0][-1] for call in run.call_args_list] + assert prefixes == ["refs/heads", "refs/remotes"] + + +# --- Logic tests --- + + +class TestListBranchRefs: + def test_list_branch_refs_merges_and_sorts(self) -> None: + """Local and remote refs merge sorted by display; ``*/HEAD`` dropped.""" + run = mock.Mock( + side_effect=_answering_run( + heads="main\nfeat/a\n", + remotes="origin/HEAD\norigin/feat/a\n", + ) + ) + + with mock.patch("goga.history.git.refs.subprocess.run", run): + refs = list_branch_refs() + + assert [ref.name for ref in refs] == ["feat/a", "main", "origin/feat/a"] + assert [ref.remote for ref in refs] == [False, False, True] + assert run.call_count == 2 + for call in run.call_args_list: + assert call.kwargs["check"] is True + assert call.kwargs["capture_output"] is True + assert call.kwargs["text"] is True + assert call.kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0" + + def test_history_git_inventory_matches_topics_git(self) -> None: + """The mirror implementations answer identically for one inventory.""" + from goga.history.git import refs as history_refs + from goga.topics.git import refs as topics_refs + + run = mock.Mock( + side_effect=_answering_run( + heads="main\nfeat/a\n", + remotes="origin/HEAD\norigin/feat/a\n", + ) + ) + + with ( + mock.patch("goga.history.git.refs.subprocess.run", run), + mock.patch("goga.topics.git.refs.subprocess.run", run), + ): + history_result = history_refs.list_branch_refs() + topics_result = topics_refs.list_branch_refs() + + assert [ref.name for ref in history_result] == [ref.name for ref in topics_result] + assert [(ref.name, ref.remote) for ref in history_result] == [(ref.name, ref.remote) for ref in topics_result] + + def test_list_branch_refs_empty_repository(self) -> None: + """An empty inventory is the norm, answered by exactly two calls.""" + run = mock.Mock(side_effect=_answering_run(heads="", remotes="")) + + with mock.patch("goga.history.git.refs.subprocess.run", run): + refs = list_branch_refs() + + assert refs == [] + assert run.call_count == 2 + + def test_list_branch_refs_propagates_git_failure(self) -> None: + """A git infrastructure failure of the listing propagates unwrapped.""" + failure = subprocess.CalledProcessError(returncode=128, cmd=["git", "for-each-ref"]) + run = mock.Mock(side_effect=failure) + + with ( + mock.patch("goga.history.git.refs.subprocess.run", run), + pytest.raises(subprocess.CalledProcessError), + ): + list_branch_refs() + + def test_list_branch_refs_propagates_missing_binary(self) -> None: + """A missing git binary surfaces as the OS-level error of the call.""" + run = mock.Mock(side_effect=FileNotFoundError("git")) + + with ( + mock.patch("goga.history.git.refs.subprocess.run", run), + pytest.raises(FileNotFoundError, match="git"), + ): + list_branch_refs() diff --git a/tests/history/statuses/__init__.py b/tests/history/statuses/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/history/statuses/conftest.py b/tests/history/statuses/conftest.py new file mode 100644 index 00000000..c860f91f --- /dev/null +++ b/tests/history/statuses/conftest.py @@ -0,0 +1,28 @@ +"""Local fixtures of the status scale cell tests.""" + +from __future__ import annotations + +import pytest +from goga.history.statuses import Stage, StatusScale + + +@pytest.fixture +def builtin_scale() -> StatusScale: + """Deterministic built-in scale — nine entries with the contract artifacts. + + The deepening order is the contract: empty, todo, defined, discovered, + backlog, designed, specified, planned, done. + """ + return StatusScale( + stages=[ + Stage(name="empty", filepath=""), + Stage(name="todo", filepath="todo.md"), + Stage(name="defined", filepath="prd.md"), + Stage(name="discovered", filepath="adr.md"), + Stage(name="backlog", filepath="task.md"), + Stage(name="designed", filepath="arch.md"), + Stage(name="specified", filepath="design.md"), + Stage(name="planned", filepath="plan.md"), + Stage(name="done", filepath="completed/plan.md"), + ] + ) diff --git a/tests/history/statuses/test_assembly.py b/tests/history/statuses/test_assembly.py new file mode 100644 index 00000000..eef0db20 --- /dev/null +++ b/tests/history/statuses/test_assembly.py @@ -0,0 +1,563 @@ +"""Contract and logic tests for the routine declared in +``goga/history/statuses/CODEMANIFEST`` with ``location: assembly.py``: + +- ``assemble_status_scale() -> scale`` — the built-in axis extended by every + tool subscribed to the status action + +The cell emits the action through the hooks platform; the tests fake the +emission by monkeypatching ``emit_hook_event`` in the assembly namespace — a +fake that captures the emission arguments and drives ``context_for`` the way +the real emission would: one view per distinct tool, hooks called in +enumeration order. The package enumeration belongs to the platform and is +pinned at its own boundary when asserted. The failure tests below the +assembly drive the real platform — the enumeration pinned, the fake facades +mounted in ``sys.modules``, the emission for real — so the surviving- +registration contract of a crashed hook is guarded at this level too; the +platform-internal failure handling is covered by ``tests/hooks/``, and the +fatal import case is asserted here by letting the fake re-raise it. Warnings +are checked with ``caplog``. +""" + +from __future__ import annotations + +import inspect +import sys +from collections.abc import Callable +from types import ModuleType +from typing import Any +from unittest import mock + +import pytest +from goga.history import statuses as cell +from goga.history.statuses import Stage, StatusScale, assemble_status_scale +from goga.history.statuses import assembly as assembly_module +from goga.hooks import HookRegistry + +Hook = Callable[[Any], None] + +_BUILTIN_NAMES = [ + "empty", + "todo", + "defined", + "discovered", + "backlog", + "designed", + "specified", + "planned", + "done", +] + +_ENUMERATION_TARGET = "goga.hooks.tools.packages.packages_distributions" + + +def _hook(*registrations: dict[str, Any]) -> Hook: + """A hook that registers the given entries in order through the delivered context.""" + + def register(context: Any) -> None: + for registration in registrations: + context.register(**registration) + + return register + + +def _fake_emission( + monkeypatch: pytest.MonkeyPatch, + tools: list[tuple[str, Hook]], +) -> dict[str, Any]: + """Replace the emission with a fake driving ``tools`` the way the real one would. + + ``tools`` pairs a tool identity with its hook, in the order the emission + delivers them — the enumeration order of the platform. The fake captures + the registry, the address, and ``context_for``, builds one view per + distinct tool at the tool's first subscription, and calls each hook with + its tool's view. + + Args: + monkeypatch: the pytest patcher restoring the emission on teardown. + tools: the ``(tool, hook)`` pairs the fake drives; the list is read + at call time, so a test may append between two assemblies. + + Returns: + The captured emission arguments — ``registry``, ``domain``, + ``action``, ``context_for``. + """ + captured: dict[str, Any] = {} + + def emit_hook_event( + registry: HookRegistry, + domain: str, + action: str, + context_for: Callable[[str], Any], + ) -> None: + captured["registry"] = registry + captured["domain"] = domain + captured["action"] = action + captured["context_for"] = context_for + + views: dict[str, Any] = {} + + for tool, hook in tools: + if tool not in views: + views[tool] = context_for(tool) + hook(views[tool]) + + monkeypatch.setattr(assembly_module, "emit_hook_event", emit_hook_event) + return captured + + +def _names(scale: StatusScale) -> list[str]: + return [stage.name for stage in scale.stages] + + +def _real_platform(monkeypatch: pytest.MonkeyPatch, packages: list[tuple[str, Hook]]) -> None: + """Mount fake ``goga_tool_*`` facades and pin the enumeration to them. + + The platform below the assembly — the enumeration boundary, the facade + import, the registrar, and the emission — runs for real; only the + installed-distributions mapping is pinned and the facades are fakes, so + the failure semantics of a hook are exercised end to end. + + Args: + monkeypatch: the pytest patcher restoring the boundary on teardown. + packages: The ``(module_name, register_hooks)`` pairs to mount, in + enumeration order. + """ + mapping: dict[str, list[str]] = {} + + for module_name, register_hooks in packages: + package = ModuleType(module_name) + package.register_hooks = register_hooks + monkeypatch.setitem(sys.modules, module_name, package) + mapping[module_name] = [module_name.replace("_", "-")] + + monkeypatch.setattr(_ENUMERATION_TARGET, lambda: mapping) + + +def _subscribe(name: str, hook: Hook) -> Hook: + """Build a facade callback subscribing one hook under ``name``.""" + + def register_hooks(hooks: Any) -> None: + hooks.subscribe("statuses", "register_statuses", name, hook) + + return register_hooks + + +# --- Contract tests --- + + +class TestAssemblyContract: + def test_routine_is_importable_from_the_cell_facade(self) -> None: + """``assemble_status_scale`` lives on the cell facade and its ``__all__``.""" + assert cell.assemble_status_scale is assemble_status_scale + assert "assemble_status_scale" in cell.__all__ + + def test_routine_takes_no_arguments(self) -> None: + """``assemble_status_scale()`` — called with no arguments.""" + assert list(inspect.signature(assemble_status_scale).parameters) == [] + + def test_routine_returns_a_status_scale(self, monkeypatch: pytest.MonkeyPatch) -> None: + """``-> scale: StatusScale`` — the return carries a ``stages`` attribute.""" + _fake_emission(monkeypatch, []) + + scale = assemble_status_scale() + + assert isinstance(scale, StatusScale) + assert isinstance(scale.stages, list) + assert all(isinstance(stage, Stage) for stage in scale.stages) + + def test_routine_does_not_cache_across_runs(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Every call assembles a fresh scale — no caching between runs.""" + tools: list[tuple[str, Hook]] = [] + _fake_emission(monkeypatch, tools) + + first = assemble_status_scale() + tools.append(("a", _hook({"name": "x", "filepath": "a/x.md", "after": "planned"}))) + second = assemble_status_scale() + + assert _names(first) == _BUILTIN_NAMES + assert _names(second) != _names(first) + assert second.stages is not first.stages + + def test_module_owns_no_package_enumeration(self) -> None: + """The platform carries the tool packages — the cell owns neither name nor helper.""" + for name in ("packages_distributions", "import_module", "_tool_packages", "_import_tool_package"): + assert not hasattr(assembly_module, name) + + +# --- Logic tests --- + + +class TestAssembleEmission: + def test_assemble_emits_the_status_action_with_per_tool_registries(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The cell emits the declared address; the view qualifies entries by the tool identity.""" + hook = _hook({"name": "pub", "filepath": "p.md", "after": "planned"}) + + captured = _fake_emission(monkeypatch, [("alpha", hook)]) + scale = assemble_status_scale() + + assert captured["domain"] == "statuses" + assert captured["action"] == "register_statuses" + assert isinstance(captured["registry"], HookRegistry) + names = _names(scale) + assert "alpha.pub" in names + assert names.index("alpha.pub") == names.index("planned") + 1 + + def test_assemble_no_package_enumeration_in_the_cell(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The platform owns the enumeration — the assembly never reads the environment.""" + _fake_emission(monkeypatch, []) + + with mock.patch(_ENUMERATION_TARGET) as enumeration: + assemble_status_scale() + + enumeration.assert_not_called() + + def test_assemble_registry_without_registrations_leaves_pure_axis(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A subscribed tool whose hook registers nothing leaves the pure built-in axis.""" + _fake_emission(monkeypatch, [("quiet", lambda _context: None)]) + + scale = assemble_status_scale() + + assert _names(scale) == _BUILTIN_NAMES + + def test_assemble_context_for_hands_one_registry_per_tool_identity(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The view builder reuses one registry per tool identity and never shares it across tools.""" + captured = _fake_emission(monkeypatch, []) + assemble_status_scale() + + context_for = captured["context_for"] + + assert context_for("alpha") is context_for("alpha") + assert context_for("alpha") is not context_for("beta") + + def test_assemble_two_tools_same_anchor_form_registration_order_block( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Two tools delivered to the same anchor stack in delivery order — one block.""" + _fake_emission( + monkeypatch, + [ + ("a", _hook({"name": "pub", "filepath": "a/p.md", "after": "planned"})), + ("b", _hook({"name": "pub", "filepath": "b/p.md", "after": "planned"})), + ], + ) + + scale = assemble_status_scale() + + names = _names(scale) + planned = names.index("planned") + assert names[planned + 1 : planned + 3] == ["a.pub", "b.pub"] + assert names[planned + 3] == "done" + + +class TestAssembleBuiltinAxis: + def test_assemble_status_scale_axis_carries_todo(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The built-in axis carries ``todo``/``todo.md`` second — the rename of the fresh-work marker.""" + _fake_emission(monkeypatch, []) + + scale = assemble_status_scale() + + assert [stage.name for stage in scale.stages] == [ + "empty", + "todo", + "defined", + "discovered", + "backlog", + "designed", + "specified", + "planned", + "done", + ] + assert scale.stages[1].filepath == "todo.md" + + def test_assemble_status_scale_builds_nine_entry_axis(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The built-in axis counts nine entries — ``todo``/``todo.md`` second, no regress to eight.""" + _fake_emission(monkeypatch, []) + + scale = assemble_status_scale() + + assert _names(scale)[:9] == _BUILTIN_NAMES + assert scale.stages[1].filepath == "todo.md" + assert len(scale.stages) == 9 + + def test_assemble_builtin_axis_order(self, monkeypatch: pytest.MonkeyPatch) -> None: + """No subscribed tools — the pure built-in axis in the contract order.""" + _fake_emission(monkeypatch, []) + + scale = assemble_status_scale() + + assert _names(scale) == _BUILTIN_NAMES + assert [stage.filepath for stage in scale.stages] == [ + "", + "todo.md", + "prd.md", + "adr.md", + "task.md", + "arch.md", + "design.md", + "plan.md", + "completed/plan.md", + ] + + +class TestAssemblePlacement: + def test_assemble_places_anchored_statuses(self, monkeypatch: pytest.MonkeyPatch) -> None: + """``after`` lands right after its anchor; ``before`` right before its anchor.""" + _fake_emission( + monkeypatch, + [ + ("a", _hook({"name": "x", "filepath": "a/x.md", "after": "planned"})), + ("b", _hook({"name": "y", "filepath": "b/y.md", "before": "done"})), + ], + ) + + scale = assemble_status_scale() + + names = _names(scale) + assert names.index("a.x") == names.index("planned") + 1 + assert names.index("b.y") == names.index("done") - 1 + + def test_assemble_both_anchors_range(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Both anchors define a range — the entry lands inside it.""" + _fake_emission( + monkeypatch, + [("a", _hook({"name": "x", "filepath": "a/x.md", "after": "defined", "before": "backlog"}))], + ) + + scale = assemble_status_scale() + + names = _names(scale) + assert names.index("discovered") < names.index("a.x") < names.index("backlog") + + def test_assembly_anchors_around_todo_axis( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + """Anchors around ``empty``/``todo``/``defined`` stay resolvable on the nine-entry axis.""" + _fake_emission( + monkeypatch, + [ + ( + "x", + _hook( + {"name": "ranged", "filepath": "x/ranged.md", "after": "empty", "before": "defined"}, + {"name": "aftertodo", "filepath": "x/aftertodo.md", "after": "todo"}, + ), + ) + ], + ) + + scale = assemble_status_scale() + + names = _names(scale) + assert names.index("empty") < names.index("x.ranged") < names.index("defined") + assert names.index("todo") < names.index("x.aftertodo") < names.index("defined") + assert caplog.text == "" + + def test_assemble_invalid_anchor_range_skips_with_warning( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + """An inverted range is invalid — the entry is skipped with a warning naming the entry.""" + _fake_emission( + monkeypatch, + [("a", _hook({"name": "x", "filepath": "a/x.md", "after": "backlog", "before": "defined"}))], + ) + + scale = assemble_status_scale() + + assert "a.x" not in _names(scale) + assert "skipping status registration a.x" in caplog.text + assert "anchor range" in caplog.text + + def test_assemble_unresolvable_anchor_warns_and_skips_entry( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + """An anchor naming no entry of the scale skips only its own entry — the rest survives.""" + _fake_emission( + monkeypatch, + [ + ( + "a", + _hook( + {"name": "good", "filepath": "g.md", "after": "planned"}, + {"name": "bad", "filepath": "b.md", "after": "nonexistent"}, + ), + ) + ], + ) + + scale = assemble_status_scale() + + names = _names(scale) + assert "a.good" in names + assert "a.bad" not in names + assert "skipping status registration a.bad" in caplog.text + + def test_assemble_unresolvable_before_anchor_skips( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + """A ``before`` anchor naming no entry of the scale skips the registration.""" + _fake_emission( + monkeypatch, + [("a", _hook({"name": "x", "filepath": "a/x.md", "before": "nonexistent.status"}))], + ) + + scale = assemble_status_scale() + + assert "a.x" not in _names(scale) + assert "skipping status registration a.x" in caplog.text + assert "unknown before anchor" in caplog.text + assert _names(scale) == _BUILTIN_NAMES + + def test_assemble_same_anchor_block_follows_delivery_order(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The block follows the delivery order of the emission — the cell does not sort tools. + + The design-review q1 regression, re-based: sorting the packages is + the platform's contract now, so the tools are delivered in reverse + alphabetical order here and the assembly must still place them in + delivery order — the order the registries were handed over in. + """ + _fake_emission( + monkeypatch, + [ + ("b", _hook({"name": "y", "filepath": "b/y.md", "after": "planned"})), + ("a", _hook({"name": "x", "filepath": "a/x.md", "after": "planned"})), + ], + ) + + scale = assemble_status_scale() + + names = _names(scale) + planned = names.index("planned") + assert names[planned + 1 : planned + 3] == ["b.y", "a.x"] + assert names[planned + 3] == "done" + + def test_assemble_two_entries_of_one_tool_same_anchor(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Two entries of one tool sharing an anchor also stack in order.""" + _fake_emission( + monkeypatch, + [ + ( + "a", + _hook( + {"name": "x", "filepath": "a/x.md", "after": "planned"}, + {"name": "z", "filepath": "a/z.md", "after": "planned"}, + ), + ) + ], + ) + + scale = assemble_status_scale() + + names = _names(scale) + planned = names.index("planned") + assert names[planned + 1 : planned + 3] == ["a.x", "a.z"] + + def test_assemble_prefix_is_the_delivered_tool_identity(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The qualifier is the tool identity the emission delivers — hyphen form included.""" + _fake_emission( + monkeypatch, + [("hello-world", _hook({"name": "x", "filepath": "hw/x.md", "after": "planned"}))], + ) + + scale = assemble_status_scale() + + assert "hello-world.x" in _names(scale) + + def test_assemble_anchor_to_earlier_tool_entry(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An entry may anchor to a tool entry accepted from an earlier tool.""" + _fake_emission( + monkeypatch, + [ + ("a", _hook({"name": "x", "filepath": "a/x.md", "after": "planned"})), + ("b", _hook({"name": "y", "filepath": "b/y.md", "after": "a.x"})), + ], + ) + + scale = assemble_status_scale() + + names = _names(scale) + assert names.index("a.x") < names.index("b.y") < names.index("done") + + +class TestAssembleFailures: + def test_assemble_crashed_hook_warns_and_keeps_earlier_registrations( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + """A hook that crashes after its first entry keeps that entry. + + The registration made before the crash lives in the tool's delivered + registry, so it still assembles into the scale, and the next tool + still contributes. The soft action turns the crash into a log + warning naming the hook, the tool, and the action. + """ + + def crashing(context: Any) -> None: + context.register("first", "a/first.md", after="planned") + raise TypeError("boom") + + _real_platform( + monkeypatch, + [ + ("goga_tool_a", _subscribe("crashed", crashing)), + ( + "goga_tool_b", + _subscribe("y", _hook({"name": "y", "filepath": "b/y.md", "before": "done"})), + ), + ], + ) + + scale = assemble_status_scale() + + names = _names(scale) + assert "a.first" in names + assert "b.y" in names + assert "hook crashed of tool a failed on statuses.register_statuses: boom" in caplog.text + + def test_assemble_rejected_registration_warns_and_continues( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + """A structural violation inside one hook skips that hook's registration only. + + The anchor-less entry raises inside the delivered registry, the hook + fails softly, and the warning names the entry — the earlier entry of + the same hook and the entries of the other tools survive. + """ + mixed = _hook( + {"name": "good", "filepath": "a/good.md", "after": "planned"}, + {"name": "bad", "filepath": "a/bad.md"}, + ) + + _real_platform( + monkeypatch, + [ + ("goga_tool_a", _subscribe("mixed", mixed)), + ( + "goga_tool_b", + _subscribe("y", _hook({"name": "y", "filepath": "b/y.md", "before": "done"})), + ), + ], + ) + + scale = assemble_status_scale() + + names = _names(scale) + assert "a.good" in names + assert "a.bad" not in names + assert "b.y" in names + assert "hook mixed of tool a failed on statuses.register_statuses" in caplog.text + assert "at least one anchor is required" in caplog.text + + def test_assemble_broken_import_is_fatal_through_the_emission(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A broken package import is the only fatal case — it propagates through the emission.""" + + def emit_hook_event( + registry: HookRegistry, + domain: str, + action: str, + context_for: Callable[[str], Any], + ) -> None: + raise ImportError("package goga_tool_bad failed to import: boom") + + monkeypatch.setattr(assembly_module, "emit_hook_event", emit_hook_event) + + with pytest.raises(ImportError, match="goga_tool_bad"): + assemble_status_scale() diff --git a/tests/history/statuses/test_registry.py b/tests/history/statuses/test_registry.py new file mode 100644 index 00000000..5fa240ac --- /dev/null +++ b/tests/history/statuses/test_registry.py @@ -0,0 +1,137 @@ +"""Contract and logic tests for the entity declared in +``goga/history/statuses/CODEMANIFEST`` with ``location: registry.py``: + +- ``StatusRegistry(builtin_stages, tool_prefix)`` — the controlled + registration surface handed to a tool package, the only way a tool status + enters the scale + +Pure registration logic — no mocks and no filesystem: anchors are stored +verbatim and resolved at scale assembly, not here. +""" + +from __future__ import annotations + +import inspect + +import pytest +from goga.history.statuses import Stage, StatusRegistry, StatusScale + +from tests.conftest import is_kw_only_dataclass + + +def _registry(builtin_scale: StatusScale, tool_prefix: str = "mkdocs") -> StatusRegistry: + """A registry over the deterministic built-in axis of the cell fixture.""" + return StatusRegistry(builtin_stages=builtin_scale.stages, tool_prefix=tool_prefix) + + +# --- Contract tests --- + + +class TestRegistryContract: + def test_entity_is_importable_from_the_cell_facade(self) -> None: + """``StatusRegistry`` lives on the cell facade and its ``__all__``.""" + import goga.history.statuses as cell + + assert cell.StatusRegistry is StatusRegistry + assert "StatusRegistry" in cell.__all__ + + def test_constructor_is_kw_only(self, builtin_scale: StatusScale) -> None: + """``StatusRegistry(builtin_stages=..., tool_prefix=...)`` — keyword-only.""" + registry = StatusRegistry(builtin_stages=builtin_scale.stages, tool_prefix="mkdocs") + + assert registry.tool_prefix == "mkdocs" + + with pytest.raises(TypeError): + StatusRegistry(builtin_scale.stages, "mkdocs") # type: ignore[misc] + + def test_register_signature(self) -> None: + """``register(name, filepath, before=None, after=None)``.""" + parameters = inspect.signature(StatusRegistry.register).parameters + + assert list(parameters) == ["self", "name", "filepath", "before", "after"] + assert parameters["before"].default is None + assert parameters["after"].default is None + + def test_stages_property_returns_the_built_in_axis_plus_entries(self, builtin_scale: StatusScale) -> None: + """``stages -> list[Stage]`` — the built-in axis plus every accepted entry.""" + registry = _registry(builtin_scale) + + assert isinstance(registry.stages, list) + assert all(isinstance(stage, Stage) for stage in registry.stages) + assert registry.stages == builtin_scale.stages + + def test_registry_is_not_frozen(self, builtin_scale: StatusScale) -> None: + """Registration is add-only state — the registry itself stays mutable.""" + assert not StatusRegistry.__dataclass_params__.frozen + assert is_kw_only_dataclass(StatusRegistry) + + +# --- Logic tests --- + + +class TestRegister: + def test_register_qualifies_name_and_appends(self, builtin_scale: StatusScale) -> None: + """``register`` stores the entry qualified and appends it after the axis.""" + registry = _registry(builtin_scale) + before = len(registry.stages) + + registry.register("published", "mkdocs/published.md", after="planned") + + assert [s.name for s in registry.stages][-1] == "mkdocs.published" + assert len(registry.stages) == before + 1 + # The built-in part is untouched — same nine names in the same order. + assert [s.name for s in registry.stages[:9]] == [s.name for s in builtin_scale.stages] + + def test_register_stores_anchors_verbatim(self, builtin_scale: StatusScale) -> None: + """Both anchors are carried as given — resolution is not done here.""" + registry = _registry(builtin_scale) + + registry.register("reviewed", "review/reviewed.md", before="done", after="scriba.translated") + + entry = registry.stages[-1] + assert entry.filepath == "review/reviewed.md" + assert entry.before == "done" + assert entry.after == "scriba.translated" + + def test_register_missing_anchor_raises(self, builtin_scale: StatusScale) -> None: + """A tool entry carries at least one anchor — otherwise a clean error.""" + registry = _registry(builtin_scale) + + with pytest.raises(ValueError, match="mkdocs"): + registry.register("x", "x.md") + + assert registry.stages == builtin_scale.stages + + def test_register_duplicate_qualified_name_raises(self, builtin_scale: StatusScale) -> None: + """The same qualified name cannot be registered twice in one registry.""" + registry = _registry(builtin_scale) + registry.register("published", "mkdocs/published.md", after="planned") + + with pytest.raises(ValueError, match=r"mkdocs\.published"): + registry.register("published", "mkdocs/published.md", after="planned") + + assert len(registry.stages) == 10 + + @pytest.mark.parametrize( + ("name", "filepath"), + [("", "x.md"), ("x", "")], + ids=["empty-name", "empty-filepath"], + ) + def test_register_empty_name_or_filepath_raises(self, builtin_scale: StatusScale, name: str, filepath: str) -> None: + """A non-empty name and a non-empty filepath are structural requirements.""" + registry = _registry(builtin_scale) + + with pytest.raises(ValueError, match=r"status entry"): + registry.register(name, filepath, after="planned") + + assert registry.stages == builtin_scale.stages + + def test_stages_returns_a_copy(self, builtin_scale: StatusScale) -> None: + """Mutating the issued list never reaches the registry content.""" + registry = _registry(builtin_scale) + registry.register("published", "mkdocs/published.md", after="planned") + + issued = registry.stages + issued.append(Stage(name="tamper", filepath="tamper.md", after="planned")) + + assert len(registry.stages) == 10 diff --git a/tests/history/statuses/test_scale.py b/tests/history/statuses/test_scale.py new file mode 100644 index 00000000..df070c1b --- /dev/null +++ b/tests/history/statuses/test_scale.py @@ -0,0 +1,214 @@ +"""Contract and logic tests for the entities declared in +``goga/history/statuses/CODEMANIFEST`` with ``location: scale.py``: + +- ``Stage(name, filepath, before=None, after=None)`` — one scale entry, a + named position anchored to the artifact that marks it +- ``StatusScale(stages)`` — the assembled partially ordered scale, the + single source of scale order and maximal-status computation + +Pure scale logic — no mocks and no filesystem: presence is decided by the +caller's ``paths`` input alone. +""" + +from __future__ import annotations + +import dataclasses +import inspect + +import pytest +from goga.history.statuses import Stage, StatusScale + + +def _tool_extended_scale(builtin_scale: StatusScale) -> StatusScale: + """Builtin axis plus two tool entries anchored after ``planned`` in package order.""" + return StatusScale( + stages=[ + *builtin_scale.stages[:8], # empty .. planned + Stage(name="mkdocs.published", filepath="mkdocs/published.md", after="planned"), + Stage(name="scriba.translated", filepath="scriba/translated.md", after="planned"), + builtin_scale.stages[8], # done + ], + ) + + +# --- Contract tests --- + + +class TestScaleContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """``Stage`` and ``StatusScale`` live on the cell facade and its ``__all__``.""" + import goga.history.statuses as cell + + assert cell.Stage is Stage + assert cell.StatusScale is StatusScale + assert "Stage" in cell.__all__ + assert "StatusScale" in cell.__all__ + + def test_stage_is_a_frozen_kw_only_dataclass(self) -> None: + """``Stage(name, filepath, before=None, after=None)`` — frozen, keyword-only.""" + stage = Stage(name="defined", filepath="prd.md") + + assert stage.name == "defined" + assert stage.filepath == "prd.md" + assert stage.before is None + assert stage.after is None + + with pytest.raises(TypeError): + Stage("defined", "prd.md") # type: ignore[misc] + with pytest.raises(dataclasses.FrozenInstanceError): + stage.name = "renamed" # type: ignore[misc] + + def test_stage_carries_qualified_anchor_names(self) -> None: + """``before``/``after`` hold the qualified names of the neighbouring entries.""" + stage = Stage(name="mkdocs.published", filepath="mkdocs/published.md", after="planned") + + assert stage.after == "planned" + assert stage.before is None + + def test_status_scale_is_a_kw_only_dataclass_over_stages(self) -> None: + """``StatusScale(stages=...)`` — keyword-only; ``stages`` is the scale content.""" + entries = [Stage(name="empty", filepath=""), Stage(name="defined", filepath="prd.md")] + scale = StatusScale(stages=entries) + + assert isinstance(scale.stages, list) + assert scale.stages == entries + + with pytest.raises(TypeError): + StatusScale(entries) # type: ignore[misc] + + def test_maximal_present_takes_paths_and_returns_status_names(self) -> None: + """``maximal_present(paths: list[str]) -> list[str]``.""" + parameters = inspect.signature(StatusScale.maximal_present).parameters + assert list(parameters) == ["self", "paths"] + + scale = StatusScale(stages=[Stage(name="empty", filepath="")]) + result = scale.maximal_present(["notes.txt"]) + + assert isinstance(result, list) + assert all(isinstance(name, str) for name in result) + + def test_resolve_status_returns_the_scale_entry(self) -> None: + """``resolve_status(name: str) -> Stage`` — the entry carries its ``name``.""" + parameters = inspect.signature(StatusScale.resolve_status).parameters + assert list(parameters) == ["self", "name"] + + scale = StatusScale(stages=[Stage(name="empty", filepath=""), Stage(name="done", filepath="completed/plan.md")]) + stage = scale.resolve_status("done") + + assert isinstance(stage, Stage) + assert stage.name == "done" + + +# --- Logic tests — maximal_present --- + + +class TestMaximalPresent: + @pytest.mark.parametrize( + ("paths", "expected"), + [ + (["todo.md"], ["todo"]), + (["prd.md"], ["defined"]), + (["adr.md"], ["discovered"]), + (["task.md"], ["backlog"]), + (["arch.md"], ["designed"]), + (["design.md"], ["specified"]), + (["plan.md"], ["planned"]), + (["completed/plan.md"], ["done"]), + ([], ["empty"]), + (["notes.txt"], ["empty"]), + (["todo.md", "prd.md"], ["defined"]), + (["plan.md", "completed/plan.md"], ["done"]), + (["prd.md", "adr.md", "task.md", "notes.txt"], ["backlog"]), + ], + ) + def test_maximal_present_progression( + self, builtin_scale: StatusScale, paths: list[str], expected: list[str] + ) -> None: + """The maximal present status of the axis for the artifacts on disk. + + The table walks the built-in axis artifact by artifact, the empty + boundaries — no artifact at all and an off-scale artifact only — + and the combinations where the deeper artifact wins: the nested + ``completed/plan.md`` over the flat ``plan.md``, and the deepest + present artifact over the shallower ones. + """ + assert builtin_scale.maximal_present(paths) == expected + + def test_maximal_present_empty_and_todo_interplay(self, builtin_scale: StatusScale) -> None: + """``empty`` against ``todo``: no artifact and an off-scale artifact stay ``empty``.""" + assert builtin_scale.maximal_present([]) == ["empty"] + assert builtin_scale.maximal_present(["notes.txt"]) == ["empty"] + assert builtin_scale.maximal_present(["todo.md"]) == ["todo"] + + def test_maximal_present_two_incomparable_tool_statuses(self, builtin_scale: StatusScale) -> None: + """Two tool entries sharing an anchor are incomparable — both stay maximal.""" + scale = _tool_extended_scale(builtin_scale) + paths = ["plan.md", "mkdocs/published.md", "scriba/translated.md"] + + assert scale.maximal_present(paths) == ["mkdocs.published", "scriba.translated"] + + def test_maximal_present_before_anchored_entry_stays_below_anchor(self, builtin_scale: StatusScale) -> None: + """A ``before``-anchored tool entry is strictly below its anchor.""" + scale = StatusScale( + stages=[ + *builtin_scale.stages[:8], # empty .. planned + Stage(name="tool.review", filepath="review.md", before="done"), + builtin_scale.stages[8], # done + ], + ) + + # Both artifacts present — the anchor outranks the tool entry. + assert scale.maximal_present(["review.md", "completed/plan.md"]) == ["done"] + # The tool artifact alone — the tool entry is maximal, and so is the + # axis entry it does not relate to. + assert scale.maximal_present(["plan.md", "review.md"]) == ["planned", "tool.review"] + + def test_maximal_present_range_entry_between_its_anchors(self, builtin_scale: StatusScale) -> None: + """A both-anchored range entry outranks its ``after`` and yields to its ``before``.""" + scale = StatusScale( + stages=[ + *builtin_scale.stages[:8], # empty .. planned + Stage(name="tool.range", filepath="range.md", after="defined", before="done"), + builtin_scale.stages[8], # done + ], + ) + + assert scale.maximal_present(["prd.md", "range.md"]) == ["tool.range"] + assert scale.maximal_present(["range.md", "completed/plan.md"]) == ["done"] + # The range entry and an unrelated axis entry are both maximal. + assert scale.maximal_present(["plan.md", "range.md"]) == ["planned", "tool.range"] + + def test_maximal_present_after_chain_is_transitive(self, builtin_scale: StatusScale) -> None: + """A chain of ``after`` anchors outranks transitively — the deepest wins.""" + scale = StatusScale( + stages=[ + *builtin_scale.stages[:8], # empty .. planned + Stage(name="tool.first", filepath="first.md", after="planned"), + Stage(name="tool.second", filepath="second.md", after="tool.first"), + builtin_scale.stages[8], # done + ], + ) + + assert scale.maximal_present(["plan.md", "first.md"]) == ["tool.first"] + assert scale.maximal_present(["first.md", "second.md"]) == ["tool.second"] + + def test_maximal_present_dedupes_repeated_paths(self, builtin_scale: StatusScale) -> None: + """A repeated path marks its entry once.""" + assert builtin_scale.maximal_present(["plan.md", "plan.md"]) == ["planned"] + + +# --- Logic tests — resolve_status --- + + +class TestResolveStatus: + def test_resolve_status_exact_qualified_name(self, builtin_scale: StatusScale) -> None: + """The exact qualified name resolves — tool and built-in alike.""" + scale = _tool_extended_scale(builtin_scale) + + assert scale.resolve_status("mkdocs.published").name == "mkdocs.published" + assert builtin_scale.resolve_status("done").name == "done" + + def test_resolve_status_unknown_name_raises(self, builtin_scale: StatusScale) -> None: + """An unknown name raises a clean error carrying the entered name.""" + with pytest.raises(ValueError, match=r"unknown status name: 'mkdocs\.published'"): + builtin_scale.resolve_status("mkdocs.published") diff --git a/tests/history/test_facade.py b/tests/history/test_facade.py new file mode 100644 index 00000000..9a02eb36 --- /dev/null +++ b/tests/history/test_facade.py @@ -0,0 +1,69 @@ +"""Facade contract test for the ``goga/history`` domain cell. + +The cell CODEMANIFEST declares twenty-one facade names: the domain types and +routines of the ``naming``/``paths``/``status``/``tree``/``prune`` modules +(topic addressing including the idempotent directory remover, and the orphan +cleanup), the git branch reader and the branch inventory embedded from the +nested ``goga.history.git`` leaf cell, and the four status scale names +embedded from the ``goga.history.statuses`` subcell (the ``->`` +re-exports). The former single-status enum ``TopicStatus`` is deleted by the +contract — the multi-status scale replaces it. +""" + +from __future__ import annotations + +import goga.history + +_HISTORY_FACADE_ALL = [ + "BranchRef", + "HistoryYear", + "Stage", + "StatusRegistry", + "StatusScale", + "TopicRecord", + "assemble_status_scale", + "collect_history_tree", + "collect_topic_statuses", + "current_year", + "ensure_topic_dir", + "list_branch_refs", + "normalize_topic_slug", + "prune_topics", + "remove_topic_dir", + "resolve_current_branch_name", + "resolve_history_root", + "resolve_topic_dir", + "resolve_topic_file", + "resolve_topic_status", + "topic_exists", +] + + +class TestHistoryFacade: + def test_history_facade_exports_twenty_one_names(self) -> None: + """The facade ``__all__`` is exactly the twenty-one contract names, alphabetical.""" + assert goga.history.__all__ == _HISTORY_FACADE_ALL + assert len(goga.history.__all__) == 21 + for name in _HISTORY_FACADE_ALL: + assert hasattr(goga.history, name), f"{name} is not defined on goga.history" + + def test_history_facade_embeds_the_git_branch_reader(self) -> None: + """The embedded routine is the git leaf cell's object, not a copy.""" + assert goga.history.resolve_current_branch_name is goga.history.git.resolve_current_branch_name + + def test_history_facade_embeds_the_git_branch_inventory(self) -> None: + """The embedded inventory names are the git leaf cell's objects, not copies.""" + assert goga.history.BranchRef is goga.history.git.BranchRef + assert goga.history.list_branch_refs is goga.history.git.list_branch_refs + + def test_history_facade_embeds_the_status_scale(self) -> None: + """The embedded scale names are the statuses subcell's objects, not copies.""" + assert goga.history.StatusScale is goga.history.statuses.StatusScale + assert goga.history.Stage is goga.history.statuses.Stage + assert goga.history.StatusRegistry is goga.history.statuses.StatusRegistry + assert goga.history.assemble_status_scale is goga.history.statuses.assemble_status_scale + + def test_history_facade_dropped_the_single_status_enum(self) -> None: + """``TopicStatus`` is deleted from the facade — the scale replaces it.""" + assert "TopicStatus" not in goga.history.__all__ + assert not hasattr(goga.history, "TopicStatus") diff --git a/tests/history/test_naming.py b/tests/history/test_naming.py new file mode 100644 index 00000000..b6334c92 --- /dev/null +++ b/tests/history/test_naming.py @@ -0,0 +1,124 @@ +"""Contract and logic tests for the routines declared in +``goga/history/CODEMANIFEST`` with ``location: naming.py``: + +- ``normalize_topic_slug(name: str) -> str`` — the pure slug transformer +- ``current_year() -> str`` — the single current-year point of the domain + +Both routines are pure; the only mock target is ``naming.datetime`` (the +mandated bare-``now()`` point), patched at the import site. +""" + +from __future__ import annotations + +import inspect +import typing +from datetime import datetime +from unittest import mock + +import pytest +from goga.history import naming +from goga.history.naming import current_year, normalize_topic_slug + + +class _FixedClock: + """Stand-in for ``datetime`` answering a fixed naive date.""" + + @staticmethod + def now() -> datetime: + return datetime(2031, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock + + +# --- Contract tests --- + + +class TestNamingContract: + def test_routines_are_importable_from_module_and_callable(self) -> None: + """Both routines are importable from ``goga.history.naming`` and callable.""" + assert callable(normalize_topic_slug) + assert callable(current_year) + assert naming.normalize_topic_slug is normalize_topic_slug + assert naming.current_year is current_year + + def test_facade_reexports_the_naming_names(self) -> None: + """The naming routines are importable from the domain facade.""" + import goga.history + + assert goga.history.normalize_topic_slug is normalize_topic_slug + assert goga.history.current_year is current_year + assert "current_year" in goga.history.__all__ + assert "normalize_topic_slug" in goga.history.__all__ + + def test_normalize_topic_slug_signature(self) -> None: + """``normalize_topic_slug(name: str) -> str`` — one positional-or-keyword parameter.""" + signature = inspect.signature(normalize_topic_slug) + assert list(signature.parameters) == ["name"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + hints = typing.get_type_hints(normalize_topic_slug) + assert hints == {"name": str, "return": str} + + def test_current_year_signature(self) -> None: + """``current_year() -> str`` — no parameters.""" + signature = inspect.signature(current_year) + assert list(signature.parameters) == [] + hints = typing.get_type_hints(current_year) + assert hints == {"return": str} + + +# --- Logic tests --- + + +class TestNormalizeTopicSlug: + @pytest.mark.parametrize( + ("name", "slug"), + [ + ("Feature/Foo_Bar", "feature-foo-bar"), + ("release/1.3.0", "release-1-3-0"), + ("Релиз/Один", ""), + ("aБb", "ab"), + ("-a--b-", "a-b"), + ("My Tool", "my-tool"), + ("feat///x", "feat-x"), + ("UPPER", "upper"), + ("123", "123"), + ("release-1-3-0", "release-1-3-0"), + ], + ) + def test_normalize_topic_slug_parametrized(self, name: str, slug: str) -> None: + """The slug grammar: lowercase → ASCII filter → hyphenate → collapse → trim. + + The last pair is idempotence — an already-normalized slug maps to + itself. + """ + assert normalize_topic_slug(name) == slug + + def test_normalize_topic_slug_fully_non_ascii_empty(self) -> None: + """A fully non-ASCII name yields the empty slug — deterministically.""" + assert normalize_topic_slug("Релиз/Один") == "" + assert normalize_topic_slug("Релиз/Один") == normalize_topic_slug("Релиз/Один") + + +class TestCurrentYear: + def test_current_year_returns_four_digits(self) -> None: + """The pinned clock answers the zero-padded 4-digit year, no parameters.""" + with mock.patch.object(naming, "datetime", _FixedClock): + year = current_year() + assert year == "2031" + assert len(year) == 4 + assert list(inspect.signature(current_year).parameters) == [] + + def test_current_year_has_no_override_and_is_uncached(self) -> None: + """Each call asks the clock anew — two pinned calls, two answers.""" + + class _SteppingClock: + calls = 0 + + @classmethod + def now(cls) -> datetime: + cls.calls += 1 + return datetime(2031 + cls.calls, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock + + with mock.patch.object(naming, "datetime", _SteppingClock): + assert current_year() == "2032" + assert current_year() == "2033" diff --git a/tests/history/test_paths.py b/tests/history/test_paths.py new file mode 100644 index 00000000..69d9534d --- /dev/null +++ b/tests/history/test_paths.py @@ -0,0 +1,350 @@ +"""Contract and logic tests for the routines declared in +``goga/history/CODEMANIFEST`` with ``location: paths.py``: + +- ``resolve_history_root() -> Path`` +- ``resolve_topic_dir(topic: str, year: str | None = None) -> Path`` +- ``resolve_topic_file(topic: str, filename: str, year: str | None = None) -> Path`` +- ``topic_exists(topic: str, year: str | None = None) -> bool`` +- ``ensure_topic_dir(name: str, year: str | None = None) -> Path`` +- ``remove_topic_dir(name: str, year: str | None = None) -> bool`` + +The path composers are pure with respect to the filesystem; ``ensure_topic_dir`` +and ``remove_topic_dir`` are the mutating routines — creation and deletion. +The single mock target is ``naming.datetime`` (the mandated bare-``now()`` +point), patched at the import site; filesystem fixtures use ``tmp_path`` + +``monkeypatch.chdir``. +""" + +from __future__ import annotations + +import inspect +import typing +from datetime import datetime +from pathlib import Path +from unittest import mock + +import pytest +from goga.history import naming, paths +from goga.history.paths import ( + ensure_topic_dir, + remove_topic_dir, + resolve_history_root, + resolve_topic_dir, + resolve_topic_file, + topic_exists, +) + + +class _FixedClock: + """Stand-in for ``datetime`` answering a fixed naive date.""" + + @staticmethod + def now() -> datetime: + return datetime(2031, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock + + +# --- Contract tests --- + + +class TestPathsContract: + def test_routines_are_importable_from_module_and_callable(self) -> None: + """All six routines are importable from ``goga.history.paths`` and callable.""" + assert callable(resolve_history_root) + assert callable(resolve_topic_dir) + assert callable(resolve_topic_file) + assert callable(topic_exists) + assert callable(ensure_topic_dir) + assert callable(remove_topic_dir) + assert paths.resolve_history_root is resolve_history_root + assert paths.resolve_topic_dir is resolve_topic_dir + assert paths.resolve_topic_file is resolve_topic_file + assert paths.topic_exists is topic_exists + assert paths.ensure_topic_dir is ensure_topic_dir + assert paths.remove_topic_dir is remove_topic_dir + + def test_facade_reexports_the_paths_names(self) -> None: + """The paths routines are importable from the domain facade.""" + import goga.history + + assert goga.history.resolve_history_root is resolve_history_root + assert goga.history.resolve_topic_dir is resolve_topic_dir + assert goga.history.resolve_topic_file is resolve_topic_file + assert goga.history.topic_exists is topic_exists + assert goga.history.ensure_topic_dir is ensure_topic_dir + for name in ( + "resolve_history_root", + "resolve_topic_dir", + "resolve_topic_file", + "topic_exists", + "ensure_topic_dir", + ): + assert name in goga.history.__all__ + + def test_resolve_topic_dir_signature(self) -> None: + """``resolve_topic_dir(topic: str, year: str | None = None) -> Path``.""" + signature = inspect.signature(resolve_topic_dir) + assert list(signature.parameters) == ["topic", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(resolve_topic_dir) + assert hints == {"topic": str, "year": str | None, "return": Path} + + def test_resolve_topic_file_signature(self) -> None: + """``resolve_topic_file(topic: str, filename: str, year: str | None = None) -> Path``.""" + signature = inspect.signature(resolve_topic_file) + assert list(signature.parameters) == ["topic", "filename", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(resolve_topic_file) + assert hints == {"topic": str, "filename": str, "year": str | None, "return": Path} + + def test_topic_exists_signature(self) -> None: + """``topic_exists(topic: str, year: str | None = None) -> bool``.""" + signature = inspect.signature(topic_exists) + assert list(signature.parameters) == ["topic", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(topic_exists) + assert hints == {"topic": str, "year": str | None, "return": bool} + + def test_resolve_history_root_signature(self) -> None: + """``resolve_history_root() -> Path`` — no parameters at all.""" + signature = inspect.signature(resolve_history_root) + assert list(signature.parameters) == [] + hints = typing.get_type_hints(resolve_history_root) + assert hints == {"return": Path} + + def test_ensure_topic_dir_signature(self) -> None: + """``ensure_topic_dir(name: str, year: str | None = None) -> Path`` — year is a kwarg.""" + signature = inspect.signature(ensure_topic_dir) + assert list(signature.parameters) == ["name", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(ensure_topic_dir) + assert hints == {"name": str, "year": str | None, "return": Path} + bound = inspect.signature(ensure_topic_dir).bind(name="X", year="2025") + assert bound.arguments == {"name": "X", "year": "2025"} + + def test_remove_topic_dir_signature(self) -> None: + """``remove_topic_dir(name: str, year: str | None = None) -> bool`` — year is a kwarg.""" + signature = inspect.signature(remove_topic_dir) + assert list(signature.parameters) == ["name", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(remove_topic_dir) + assert hints == {"name": str, "year": str | None, "return": bool} + bound = inspect.signature(remove_topic_dir).bind(name="X", year="2025") + assert bound.arguments == {"name": "X", "year": "2025"} + + def test_history_root_helper_points_at_the_tree(self) -> None: + """The private helper delegates to the public composer — one source of the root.""" + assert paths._history_root() == Path(".goga") / "history" + assert paths._history_root() == resolve_history_root() + + +# --- Logic tests --- + + +class TestResolveHistoryRoot: + def test_resolve_history_root_composes_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The composer answers the relative root — pure, nothing is created.""" + monkeypatch.chdir(tmp_path) + result = resolve_history_root() + assert result == Path(".goga") / "history" + assert not result.exists() + assert not (tmp_path / ".goga").exists() + + +class TestResolveTopicDir: + def test_resolve_topic_dir_composes_and_normalizes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Branch input normalizes; no year means the current year; nothing is created.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(naming, "datetime", _FixedClock): + assert resolve_topic_dir("Feature/Foo_Bar") == Path(".goga/history/2031/feature-foo-bar") + assert resolve_topic_dir("release-1-3-0", year="2025") == Path(".goga/history/2025/release-1-3-0") + assert not (tmp_path / ".goga").exists() + + def test_resolve_topic_dir_is_idempotent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The same input yields the same path on every call.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(naming, "datetime", _FixedClock): + first = resolve_topic_dir("My Tool") + second = resolve_topic_dir("My Tool") + assert first == second == Path(".goga/history/2031/my-tool") + + def test_resolve_topic_dir_empty_slug_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A fully non-ASCII topic raises the clean empty-slug error, no fallback.""" + monkeypatch.chdir(tmp_path) + + with pytest.raises(ValueError, match="normalizes to an empty topic slug"): + resolve_topic_dir("Релиз/Один") + + def test_resolve_topic_dir_empty_year_string_means_current( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A falsy year (``""`` from an empty CLI value) means "not set", not path degradation.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(naming, "datetime", _FixedClock): + assert resolve_topic_dir("feat-x", year="") == Path(".goga/history/2031/feat-x") + + +class TestResolveTopicFile: + def test_resolve_topic_file_appends_filename(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The filename is appended verbatim; the file is not created.""" + monkeypatch.chdir(tmp_path) + path = resolve_topic_file("history-commands", "plan.md", year="2026") + assert path == Path(".goga/history/2026/history-commands/plan.md") + assert not path.exists() + + @pytest.mark.parametrize("filename", ["noext", ".md", "plan."]) + def test_resolve_topic_file_rejects_extensionless( + self, filename: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An extensionless filename — including dotfiles and trailing dots — is a clean error.""" + monkeypatch.chdir(tmp_path) + + with pytest.raises(ValueError, match="must carry an extension"): + resolve_topic_file("history-commands", filename, year="2026") + + def test_resolve_topic_file_empty_slug_raises_via_dir_composer( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The file composer reuses the single directory composer — its empty-slug error stands.""" + monkeypatch.chdir(tmp_path) + + with pytest.raises(ValueError, match="normalizes to an empty topic slug"): + resolve_topic_file("Релиз/Один", "plan.md", year="2026") + + +class TestTopicExists: + def test_topic_exists_true_for_directory(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Only a directory occupies a topic — and only for its own year.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".goga" / "history" / "2026" / "feat-x").mkdir(parents=True) + (tmp_path / ".goga" / "history" / "2026" / "stray").write_text("not a topic", encoding="utf-8") + assert topic_exists("feat/x", year="2026") is True + assert topic_exists("feat/x", year="2025") is False + assert topic_exists("stray", year="2026") is False + + def test_topic_exists_absent_root_is_false(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A missing history root is "no", not an error — and creates nothing.""" + monkeypatch.chdir(tmp_path) + assert topic_exists("feat-x") is False + assert not (tmp_path / ".goga").exists() + + +class TestEnsureTopicDir: + def test_ensure_topic_dir_creates_explicit_year(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An explicit year scopes creation to that year — the D1 current-year-only fix.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(naming, "datetime", _FixedClock): + created = ensure_topic_dir("Feature/Foo_Bar", year="2025") + repeated = ensure_topic_dir("Feature/Foo_Bar", year="2025") + expected = Path(".goga/history/2025/feature-foo-bar") + assert created == expected + assert repeated == expected + assert (tmp_path / ".goga" / "history" / "2025" / "feature-foo-bar").is_dir() + assert not (tmp_path / ".goga" / "history" / "2031").exists() + + def test_ensure_topic_dir_defaults_to_current_year(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Without a year the current year applies — the pre-existing behavior stands.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(naming, "datetime", _FixedClock): + created = ensure_topic_dir("X") + assert created == Path(".goga/history/2031/x") + assert (tmp_path / ".goga" / "history" / "2031" / "x").is_dir() + + def test_ensure_topic_dir_creates_idempotently(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Creation normalizes, defaults to the current year, and is idempotent.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(naming, "datetime", _FixedClock): + first = ensure_topic_dir("Feature/X") + second = ensure_topic_dir("feature-x") + expected = Path(".goga/history/2031/feature-x") + assert first == expected + assert second == expected + assert first.is_dir() + assert list(first.iterdir()) == [] + + def test_ensure_topic_dir_creates_parents(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Missing parents (.goga/history/<year>) are created on the way.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(naming, "datetime", _FixedClock): + created = ensure_topic_dir("feat-y") + assert created == Path(".goga/history/2031/feat-y") + assert (tmp_path / ".goga" / "history" / "2031").is_dir() + + def test_ensure_topic_dir_empty_slug_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An empty slug is the directory composer's clean error — nothing is created.""" + monkeypatch.chdir(tmp_path) + + with pytest.raises(ValueError, match="normalizes to an empty topic slug"): + ensure_topic_dir("Релиз/Один") + assert not (tmp_path / ".goga").exists() + + def test_ensure_topic_dir_stray_file_propagates_oserror( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A stray file at the topic path is an ``OSError`` from mkdir, propagated.""" + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + year_dir.mkdir(parents=True) + (year_dir / "feat-x").write_text("not a topic", encoding="utf-8") + + with pytest.raises(OSError, match="feat-x"): + ensure_topic_dir("feat-x", year="2026") + + +class TestRemoveTopicDir: + def test_remove_topic_dir_deletes_whole_directory(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The whole directory goes — nested ``completed/`` with it; the year directory stays.""" + monkeypatch.chdir(tmp_path) + topic_dir = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" + (topic_dir / "completed").mkdir(parents=True) + (topic_dir / "prd.md").write_text("problem", encoding="utf-8") + (topic_dir / "completed" / "plan.md").write_text("plan", encoding="utf-8") + + assert remove_topic_dir("Feature/Foo_Bar", "2026") is True + assert not topic_dir.exists() + assert not (topic_dir / "completed").exists() + assert (tmp_path / ".goga" / "history" / "2026").is_dir() + + def test_remove_topic_dir_absent_returns_false(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An absent directory is idempotent absence — False, not an error.""" + monkeypatch.chdir(tmp_path) + assert remove_topic_dir("absent-topic", "2026") is False + assert not (tmp_path / ".goga").exists() + + def test_remove_topic_dir_stray_file_returns_false(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A stray file named like the slug does not occupy a topic — it stays in place.""" + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + year_dir.mkdir(parents=True) + (year_dir / "feat-a").write_text("not a topic", encoding="utf-8") + + assert remove_topic_dir("feat-a", "2026") is False + assert (year_dir / "feat-a").is_file() + + def test_remove_topic_dir_empty_slug_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An empty slug is the directory composer's clean error — nothing is deleted.""" + monkeypatch.chdir(tmp_path) + + with pytest.raises(ValueError, match="normalizes to an empty topic slug"): + remove_topic_dir("", "2026") diff --git a/tests/history/test_prune.py b/tests/history/test_prune.py new file mode 100644 index 00000000..790d088e --- /dev/null +++ b/tests/history/test_prune.py @@ -0,0 +1,317 @@ +"""Contract and logic tests for the routine declared in +``goga/history/CODEMANIFEST`` with ``location: prune.py``: + +- ``prune_topics(year: str | None = None, dry_run: bool = False) -> removed: list[str]`` + +The orphan decision is the unit under test. The tree inventory is real +(``tmp_path`` + ``monkeypatch.chdir``); the git boundary is mocked at the +import site — ``goga.history.prune.list_branch_refs`` — except in the +filesystem-only test, which intercepts ``subprocess.run`` of the nested git +cell; the clock is the mandated ``naming.datetime`` point patched with a +fixed date. The facade exposure of the routine belongs to the facade task. +""" + +from __future__ import annotations + +import inspect +import subprocess +import typing +from datetime import datetime +from pathlib import Path +from unittest import mock + +import pytest +from goga.history import naming, prune +from goga.history.git import BranchRef +from goga.history.naming import normalize_topic_slug +from goga.history.prune import prune_topics +from goga.topics.creation import check_branch_occupancy + + +class _FixedClock: + """Stand-in for ``datetime`` answering a fixed naive date.""" + + @staticmethod + def now() -> datetime: + return datetime(2026, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock + + +def _topic(root: Path, year: str, name: str, *artifacts: str) -> Path: + """Create one topic directory of a year, with optional artifact files.""" + topic_dir = root / ".goga" / "history" / year / name + topic_dir.mkdir(parents=True, exist_ok=True) + + for artifact in artifacts: + path = topic_dir / artifact + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(artifact, encoding="utf-8") + + return topic_dir + + +def _inventory(refs: list[BranchRef]) -> mock._patch[mock.Mock]: + """Patch the git boundary of the prune module with a fixed inventory.""" + return mock.patch("goga.history.prune.list_branch_refs", return_value=refs) + + +def _inventory_run(heads: str, remotes: str) -> mock.Mock: + """A ``subprocess.run`` stand-in answering the two ``for-each-ref`` calls.""" + answers = {"refs/heads": heads, "refs/remotes": remotes} + + def answering(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(command, 0, stdout=answers[command[3]]) + + return mock.Mock(side_effect=answering) + + +# --- Contract tests --- + + +class TestPruneContract: + def test_routine_is_importable_from_module_and_callable(self) -> None: + """``prune_topics`` is importable from ``goga.history.prune`` and callable.""" + assert callable(prune_topics) + assert prune.prune_topics is prune_topics + + def test_prune_topics_signature(self) -> None: + """``prune_topics(year: str | None = None, dry_run: bool = False) -> list[str]``.""" + signature = inspect.signature(prune_topics) + assert list(signature.parameters) == ["year", "dry_run"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + assert signature.parameters["dry_run"].default is False + hints = typing.get_type_hints(prune_topics) + assert hints == {"year": str | None, "dry_run": bool, "return": list[str]} + bound = inspect.signature(prune_topics).bind(year="2025", dry_run=True) + assert bound.arguments == {"year": "2025", "dry_run": True} + + +# --- Logic tests --- + + +class TestPruneTopics: + def test_prune_topics_deletes_orphans_keeps_hosted(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Orphans go — a done orphan on equal terms; the hosted topic of the year stays.""" + monkeypatch.chdir(tmp_path) + hosted = _topic(tmp_path, "2026", "feat-a", "prd.md") + done = _topic(tmp_path, "2026", "done-c", "completed/plan.md") + orphan = _topic(tmp_path, "2026", "orphan-b", "prd.md") + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/feat/a", remote=True), + ] + + with _inventory(inventory): + removed = prune_topics("2026") + assert removed == ["done-c", "orphan-b"] + assert hosted.is_dir() + assert not done.exists() + assert not orphan.exists() + + def test_prune_remote_short_name_protects(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The short name of a remote-tracking ref protects without a local branch.""" + monkeypatch.chdir(tmp_path) + topic_dir = _topic(tmp_path, "2026", "feat-a", "prd.md") + + with _inventory([BranchRef(name="origin/feat/a", remote=True)]): + assert prune_topics("2026") == [] + assert topic_dir.is_dir() + + def test_prune_protection_is_year_independent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A branch protects same-named topics of every year — the default and an explicit year.""" + inventory = [BranchRef(name="feat/a", remote=False)] + + def build(root: Path) -> tuple[Path, Path]: + return _topic(root, "2025", "feat-a", "prd.md"), _topic(root, "2026", "feat-a", "prd.md") + + current_year_root = tmp_path / "current" + explicit_year_root = tmp_path / "explicit" + old_current, new_current = build(current_year_root) + old_explicit, new_explicit = build(explicit_year_root) + + with mock.patch.object(naming, "datetime", _FixedClock), _inventory(inventory): + monkeypatch.chdir(current_year_root) + assert prune_topics() == [] + monkeypatch.chdir(explicit_year_root) + assert prune_topics("2025") == [] + assert old_current.is_dir() + assert new_current.is_dir() + assert old_explicit.is_dir() + assert new_explicit.is_dir() + + def test_prune_dry_run_lists_what_real_run_deletes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The dry pass lists exactly what the real pass deletes — and deletes nothing itself.""" + dry_root = tmp_path / "dry" + wet_root = tmp_path / "wet" + + def build(root: Path) -> tuple[Path, Path]: + return _topic(root, "2026", "orphan-b", "prd.md"), _topic(root, "2026", "done-c", "completed/plan.md") + + dry_orphan, dry_done = build(dry_root) + wet_orphan, wet_done = build(wet_root) + + with _inventory([]): + monkeypatch.chdir(dry_root) + assert prune_topics("2026", dry_run=True) == ["done-c", "orphan-b"] + monkeypatch.chdir(wet_root) + assert prune_topics("2026") == ["done-c", "orphan-b"] + assert dry_orphan.is_dir() + assert dry_done.is_dir() + assert not wet_orphan.exists() + assert not wet_done.exists() + + def test_prune_topics_returns_sorted_unique_slugs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The result is the sorted orphan slug set — no duplicates, dry or wet alike.""" + monkeypatch.chdir(tmp_path) + _topic(tmp_path, "2026", "b-orphan", "prd.md") + _topic(tmp_path, "2026", "a-orphan", "prd.md") + + with _inventory([]): + assert prune_topics("2026", dry_run=True) == ["a-orphan", "b-orphan"] + + +class TestPruneTopicsOracle: + def test_prune_oracle_matches_check_branch_occupancy(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The orphan decision agrees with the creation occupancy oracle on exact branch names.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/hot/b", remote=True), + BranchRef(name="main", remote=False), + ] + + for entered in ["feat/a", "hot/b", "main", "other"]: + slug = normalize_topic_slug(entered) + # (1) the occupancy oracle on the empty tree — only the git oracles answer + with mock.patch("goga.topics.creation.list_branch_refs", return_value=inventory): + occupied = check_branch_occupancy(entered, slug, "2026") is not None + # (2) the topic directory of the slug comes to exist + _topic(tmp_path, "2026", slug) + # (3) the prune oracle measures the same names + with _inventory(inventory): + candidates = prune_topics("2026", dry_run=True) + assert occupied == (slug not in candidates), entered + + +class TestPruneTopicsNegatives: + def test_prune_topics_absent_year_returns_empty_list(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An absent year yields no topics — nothing is deleted, nothing is touched.""" + monkeypatch.chdir(tmp_path) + kept = _topic(tmp_path, "2026", "feat-a", "prd.md") + + with ( + _inventory([BranchRef(name="feat/a", remote=False)]), + mock.patch("goga.history.prune.remove_topic_dir") as remover, + ): + assert prune_topics("1999") == [] + remover.assert_not_called() + assert kept.is_dir() + + def test_prune_topics_queries_inventory_even_for_empty_year( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A year without topics still reads the branch inventory — no short-circuit.""" + monkeypatch.chdir(tmp_path) + + with _inventory([]) as inventory: + assert prune_topics("1999") == [] + + inventory.assert_called_once_with() + + def test_prune_topics_propagates_inventory_git_failure( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A git failure of the ref listing propagates to the caller.""" + monkeypatch.chdir(tmp_path) + failure = subprocess.CalledProcessError(returncode=128, cmd=["git", "for-each-ref"]) + + with ( + mock.patch("goga.history.prune.list_branch_refs", side_effect=failure), + pytest.raises(subprocess.CalledProcessError), + ): + prune_topics("2026") + + def test_prune_topics_never_mutates_git(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The only git invocations of the flow are the read-only ref listings — wet and dry alike.""" + monkeypatch.chdir(tmp_path) + orphan = _topic(tmp_path, "2026", "orphan-b", "prd.md") + done = _topic(tmp_path, "2026", "done-c", "completed/plan.md") + runner = _inventory_run(heads="main\n", remotes="") + + with mock.patch("goga.history.git.refs.subprocess.run", runner): + assert prune_topics("2026", dry_run=True) == ["done-c", "orphan-b"] + assert orphan.is_dir() + assert done.is_dir() + assert prune_topics("2026") == ["done-c", "orphan-b"] + assert not orphan.exists() + assert not done.exists() + assert runner.call_count == 4 # two ref listings per pass, one dry and one wet + for call in runner.call_args_list: + command = call.args[0] + assert command[:2] == ["git", "for-each-ref"] + assert not {"branch", "push", "update-ref", "checkout", "rm"} & set(command) + + +class TestPruneTopicsEdges: + def test_prune_topics_empty_tree_returns_empty_list(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A missing history root is an empty result — not an error, and nothing is created.""" + monkeypatch.chdir(tmp_path) + + with _inventory([]): + assert prune_topics() == [] + assert not (tmp_path / ".goga").exists() + + def test_prune_topics_only_resolved_year_touched(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An explicit year scopes the cleanup — other years stay untouched.""" + monkeypatch.chdir(tmp_path) + old = _topic(tmp_path, "2025", "orphan-old", "prd.md") + new = _topic(tmp_path, "2026", "orphan-new", "prd.md") + + with mock.patch.object(naming, "datetime", _FixedClock), _inventory([]): + assert prune_topics("2025") == ["orphan-old"] + assert not old.exists() + assert (tmp_path / ".goga" / "history" / "2025").is_dir() # the emptied year directory stays + assert new.is_dir() + + def test_prune_topics_empty_string_year_means_current_year( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An empty-string year is "not set" — the current year alone supplies the candidates.""" + monkeypatch.chdir(tmp_path) + old = _topic(tmp_path, "2025", "orphan-old", "prd.md") + new = _topic(tmp_path, "2026", "orphan-new", "prd.md") + + with mock.patch.object(naming, "datetime", _FixedClock), _inventory([]): + assert prune_topics("", dry_run=True) == ["orphan-new"] + assert old.is_dir() + assert new.is_dir() + + def test_prune_topics_normalizes_tree_names_for_protection( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A manual unnormalized directory is protected through its normalized slug.""" + monkeypatch.chdir(tmp_path) + manual = _topic(tmp_path, "2026", "Feature_Foo", "prd.md") + twin = _topic(tmp_path, "2026", "feature-foo", "prd.md") + + with _inventory([BranchRef(name="feature/foo", remote=False)]): + assert prune_topics("2026") == [] + assert manual.is_dir() + assert twin.is_dir() + + def test_prune_topics_unnormalized_orphan_dir_stays(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A manual unnormalized orphan is listed but unreachable for deletion — wet and dry agree.""" + dry_root = tmp_path / "dry" + wet_root = tmp_path / "wet" + dry_manual = _topic(dry_root, "2026", "Feature_Foo", "prd.md") + wet_manual = _topic(wet_root, "2026", "Feature_Foo", "prd.md") + + with _inventory([]): + monkeypatch.chdir(dry_root) + assert prune_topics("2026", dry_run=True) == ["feature-foo"] + monkeypatch.chdir(wet_root) + assert prune_topics("2026") == ["feature-foo"] + assert dry_manual.is_dir() + assert wet_manual.is_dir() diff --git a/tests/history/test_status.py b/tests/history/test_status.py new file mode 100644 index 00000000..039433a2 --- /dev/null +++ b/tests/history/test_status.py @@ -0,0 +1,280 @@ +"""Contract and logic tests for the entities declared in +``goga/history/CODEMANIFEST`` with ``location: status.py``: + +- ``TopicRecord(topic: str, statuses: list[str])`` +- ``resolve_topic_status(topic_dir: Path, scale: StatusScale) -> statuses: list[str]`` +- ``collect_topic_statuses(year: str | None = None, scale: StatusScale | None = None) -> records: list[TopicRecord]`` + +The resolver and the collector are read-only with respect to the filesystem; +the scale is a parameter assembled once per command run — never here per +topic. The mocks are patched at their import sites: ``naming.datetime`` (the +mandated bare-``now()`` point) and ``status.assemble_status_scale`` (the +assembly reuse counter). Filesystem fixtures use ``tmp_path`` + +``monkeypatch.chdir``; the scale fixtures are hand-assembled lists of +``Stage``, per the design scenarios. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import typing +from datetime import datetime +from pathlib import Path +from unittest import mock + +import pytest +from goga.history import naming, status +from goga.history.status import ( + TopicRecord, + collect_topic_statuses, + resolve_topic_status, +) +from goga.history.statuses import Stage, StatusScale + +from tests.conftest import is_kw_only_dataclass + + +class _FixedClock: + """Stand-in for ``datetime`` answering a fixed naive date.""" + + @staticmethod + def now() -> datetime: + return datetime(2031, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock + + +def _builtin_scale() -> StatusScale: + """Deterministic built-in scale — nine entries with the contract artifacts.""" + return StatusScale( + stages=[ + Stage(name="empty", filepath=""), + Stage(name="todo", filepath="todo.md"), + Stage(name="defined", filepath="prd.md"), + Stage(name="discovered", filepath="adr.md"), + Stage(name="backlog", filepath="task.md"), + Stage(name="designed", filepath="arch.md"), + Stage(name="specified", filepath="design.md"), + Stage(name="planned", filepath="plan.md"), + Stage(name="done", filepath="completed/plan.md"), + ] + ) + + +def _tool_scale() -> StatusScale: + """Built-in scale plus one tool entry anchored after ``planned``.""" + return StatusScale( + stages=[ + *_builtin_scale().stages, + Stage(name="mkdocs.published", filepath="mkdocs/published.md", after="planned"), + ] + ) + + +# --- Contract tests --- + + +class TestStatusContract: + def test_entities_are_importable_from_module_and_callable(self) -> None: + """All three entities are importable from ``goga.history.status``.""" + assert status.TopicRecord is TopicRecord + assert callable(resolve_topic_status) + assert callable(collect_topic_statuses) + assert status.resolve_topic_status is resolve_topic_status + assert status.collect_topic_statuses is collect_topic_statuses + + def test_facade_reexports_the_status_names(self) -> None: + """The status entities are importable from the domain facade.""" + import goga.history + + assert goga.history.TopicRecord is TopicRecord + assert goga.history.resolve_topic_status is resolve_topic_status + assert goga.history.collect_topic_statuses is collect_topic_statuses + for name in ("TopicRecord", "resolve_topic_status", "collect_topic_statuses"): + assert name in goga.history.__all__ + + def test_single_status_enum_is_deleted(self) -> None: + """``TopicStatus`` and the artifact progression constant are gone from the module.""" + assert not hasattr(status, "TopicStatus") + assert not hasattr(status, "_ARTIFACT_PROGRESSION") + + def test_topic_record_is_frozen_kw_only_dataclass(self) -> None: + """``@dataclass(frozen=True, kw_only=True)`` with the fields ``topic`` and ``statuses``.""" + assert dataclasses.is_dataclass(TopicRecord) + assert TopicRecord.__dataclass_params__.frozen is True + assert is_kw_only_dataclass(TopicRecord) + assert typing.get_type_hints(TopicRecord) == {"topic": str, "statuses": list[str]} + record = TopicRecord(topic="t", statuses=["planned", "mkdocs.published"]) + assert record.topic == "t" + assert record.statuses == ["planned", "mkdocs.published"] + with pytest.raises(dataclasses.FrozenInstanceError): + record.topic = "other" # type: ignore[misc] + with pytest.raises(TypeError): + TopicRecord("t", ["planned"]) # type: ignore[misc] + + def test_resolve_topic_status_signature(self) -> None: + """``resolve_topic_status(topic_dir: Path, scale: StatusScale) -> list[str]``.""" + signature = inspect.signature(resolve_topic_status) + assert list(signature.parameters) == ["topic_dir", "scale"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + hints = typing.get_type_hints(resolve_topic_status) + assert hints == {"topic_dir": Path, "scale": StatusScale, "return": list[str]} + + def test_collect_topic_statuses_signature(self) -> None: + """``collect_topic_statuses(year=None, scale=None) -> list[TopicRecord]``.""" + signature = inspect.signature(collect_topic_statuses) + assert list(signature.parameters) == ["year", "scale"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + assert signature.parameters["scale"].default is None + hints = typing.get_type_hints(collect_topic_statuses) + assert hints == {"year": str | None, "scale": StatusScale | None, "return": list[TopicRecord]} + + +# --- Logic tests --- + + +class TestResolveTopicStatus: + @pytest.mark.parametrize( + ("artifact", "expected"), + [ + ("todo.md", ["todo"]), + ("prd.md", ["defined"]), + ("adr.md", ["discovered"]), + ("task.md", ["backlog"]), + ("arch.md", ["designed"]), + ("design.md", ["specified"]), + ("plan.md", ["planned"]), + ("completed/plan.md", ["done"]), + ], + ) + def test_resolve_topic_status_progression( + self, + artifact: str, + expected: list[str], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Each progression artifact alone resolves to its mapped status.""" + monkeypatch.chdir(tmp_path) + topic_dir = tmp_path / ".goga" / "history" / "2026" / "t" + artifact_path = topic_dir / artifact + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_text("artifact", encoding="utf-8") + assert resolve_topic_status(topic_dir, _builtin_scale()) == expected + + def test_resolve_topic_status_completed_wins_over_flat( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``completed/plan.md`` outranks every flat artifact present alongside it.""" + monkeypatch.chdir(tmp_path) + topic_dir = tmp_path / ".goga" / "history" / "2026" / "t" + topic_dir.mkdir(parents=True) + (topic_dir / "prd.md").write_text("flat artifact", encoding="utf-8") + (topic_dir / "plan.md").write_text("flat artifact", encoding="utf-8") + (topic_dir / "completed").mkdir() + (topic_dir / "completed" / "plan.md").write_text("nested artifact", encoding="utf-8") + assert resolve_topic_status(topic_dir, _builtin_scale()) == ["done"] + + def test_resolve_topic_status_empty_when_no_artifact_present( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An empty or absent directory, and files outside the scale, resolve to empty.""" + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + empty_dir = year_dir / "empty-topic" + empty_dir.mkdir(parents=True) + assert resolve_topic_status(empty_dir, _builtin_scale()) == ["empty"] + assert resolve_topic_status(year_dir / "absent-topic", _builtin_scale()) == ["empty"] + stray_dir = year_dir / "stray-topic" + stray_dir.mkdir(parents=True) + (stray_dir / "notes.md").write_text("outside the scale", encoding="utf-8") + assert resolve_topic_status(stray_dir, _builtin_scale()) == ["empty"] + + def test_resolve_topic_status_multi_statuses(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A tool artifact outranks the built-in entry it is anchored after.""" + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + tool_topic = year_dir / "release-1-3-0" + (tool_topic / "mkdocs").mkdir(parents=True) + (tool_topic / "plan.md").write_text("plan", encoding="utf-8") + (tool_topic / "mkdocs" / "published.md").write_text("published", encoding="utf-8") + assert resolve_topic_status(tool_topic, _tool_scale()) == ["mkdocs.published"] + plain_topic = year_dir / "plain" + plain_topic.mkdir(parents=True) + (plain_topic / "plan.md").write_text("plan", encoding="utf-8") + assert resolve_topic_status(plain_topic, _tool_scale()) == ["planned"] + + +class TestCollectTopicStatuses: + def test_collect_topic_statuses_sorted_records(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Only directories count as topics; records are sorted with resolved statuses.""" + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "zeta").mkdir(parents=True) + (year_dir / "alpha").mkdir(parents=True) + (year_dir / "alpha" / "plan.md").write_text("plan", encoding="utf-8") + (year_dir / "mid").mkdir(parents=True) + (year_dir / "mid" / "prd.md").write_text("prd", encoding="utf-8") + (year_dir / "stray.txt").write_text("not a topic", encoding="utf-8") + records = collect_topic_statuses(year="2026", scale=_builtin_scale()) + assert [record.topic for record in records] == ["alpha", "mid", "zeta"] + assert records[0].statuses == ["planned"] + assert records[1].statuses == ["defined"] + assert records[2].statuses == ["empty"] + + def test_collect_topic_statuses_reuses_scale(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A passed scale is used as-is — the assembly is not repeated per run.""" + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "alpha").mkdir(parents=True) + (year_dir / "alpha" / "plan.md").write_text("plan", encoding="utf-8") + (year_dir / "beta").mkdir(parents=True) + assembly = mock.patch.object(status, "assemble_status_scale", wraps=status.assemble_status_scale) + + with assembly as assemble: + records = collect_topic_statuses("2026", _builtin_scale()) + assert [record.topic for record in records] == ["alpha", "beta"] + assert records[0].statuses == ["planned"] + assert records[1].statuses == ["empty"] + assert assemble.call_count == 0 + + def test_collect_topic_statuses_assembles_scale_once_when_none( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``scale=None`` assembles the scale exactly once for the whole run.""" + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "alpha").mkdir(parents=True) + (year_dir / "alpha" / "plan.md").write_text("plan", encoding="utf-8") + (year_dir / "beta").mkdir(parents=True) + + with mock.patch.object(status, "assemble_status_scale", return_value=_builtin_scale()) as assemble: + records = collect_topic_statuses("2026") + assert [record.topic for record in records] == ["alpha", "beta"] + assert records[0].statuses == ["planned"] + assert records[1].statuses == ["empty"] + assert assemble.call_count == 1 + + def test_collect_topic_statuses_absent_year_empty(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An absent year yields an empty list — not an error, and nothing is created.""" + monkeypatch.chdir(tmp_path) + assert collect_topic_statuses(year="1999", scale=_builtin_scale()) == [] + assert not (tmp_path / ".goga").exists() + + def test_collect_topic_statuses_empty_year_string_means_current( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A falsy year means the current year — not the history root's year children.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".goga" / "history" / "2025" / "old-topic").mkdir(parents=True) + (tmp_path / ".goga" / "history" / "2031" / "t").mkdir(parents=True) + + with mock.patch.object(naming, "datetime", _FixedClock): + records = collect_topic_statuses(year="", scale=_builtin_scale()) + assert [record.topic for record in records] == ["t"] + assert "2025" not in [record.topic for record in records] + assert "2031" not in [record.topic for record in records] diff --git a/tests/history/test_tree.py b/tests/history/test_tree.py new file mode 100644 index 00000000..5e3cd0fe --- /dev/null +++ b/tests/history/test_tree.py @@ -0,0 +1,141 @@ +"""Contract and logic tests for the entities declared in +``goga/history/CODEMANIFEST`` with ``location: tree.py``: + +- ``HistoryYear(year: str, topics: list[str])`` +- ``collect_history_tree(year: str | None = None) -> tree: list[HistoryYear]`` + +The collector is read-only with respect to the filesystem and carries names +only — no statuses are resolved and the clock is never read. The year +selection is optional: ``None`` and the empty string mean no selection — +the full tree; a year missing from the tree yields an empty list. Filesystem +fixtures use ``tmp_path`` + ``monkeypatch.chdir``; no mocks are needed. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import typing +from pathlib import Path + +import pytest +from goga.history import tree +from goga.history.tree import HistoryYear, collect_history_tree + +from tests.conftest import is_kw_only_dataclass + +# --- Contract tests --- + + +class TestTreeContract: + def test_entities_are_importable_from_module_and_callable(self) -> None: + """Both entities are importable from ``goga.history.tree``.""" + assert tree.HistoryYear is HistoryYear + assert callable(collect_history_tree) + assert tree.collect_history_tree is collect_history_tree + + def test_facade_reexports_the_tree_names(self) -> None: + """The tree entities are importable from the domain facade.""" + import goga.history + + assert goga.history.HistoryYear is HistoryYear + assert goga.history.collect_history_tree is collect_history_tree + for name in ("HistoryYear", "collect_history_tree"): + assert name in goga.history.__all__ + + def test_history_year_is_frozen_kw_only_dataclass(self) -> None: + """``@dataclass(frozen=True, kw_only=True)`` with the fields ``year`` and ``topics``.""" + assert dataclasses.is_dataclass(HistoryYear) + assert HistoryYear.__dataclass_params__.frozen is True + assert is_kw_only_dataclass(HistoryYear) + assert typing.get_type_hints(HistoryYear) == {"year": str, "topics": list[str]} + record = HistoryYear(year="2026", topics=["history-commands"]) + assert record.year == "2026" + assert record.topics == ["history-commands"] + with pytest.raises(dataclasses.FrozenInstanceError): + record.year = "2025" # type: ignore[misc] + with pytest.raises(TypeError): + HistoryYear("2026", []) # type: ignore[misc] + + def test_collect_history_tree_signature(self) -> None: + """``collect_history_tree(year: str | None = None) -> list[HistoryYear]``.""" + signature = inspect.signature(collect_history_tree) + assert list(signature.parameters) == ["year"] + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(collect_history_tree) + assert hints == {"year": str | None, "return": list[HistoryYear]} + + def test_collect_history_tree_call_without_arguments_is_valid(self) -> None: + """The no-argument call stays valid — ``goga/topics/_disk_slugs`` relies on it.""" + signature = inspect.signature(collect_history_tree) + signature.bind() # no TypeError — every parameter carries a default + + +# --- Logic tests --- + + +class TestCollectHistoryTree: + def test_collect_history_tree_full_shape(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Four-ASCII-digit year directories ascending; topics alphabetical; everything else ignored.""" + monkeypatch.chdir(tmp_path) + root = tmp_path / ".goga" / "history" + (root / "2025" / "b-topic").mkdir(parents=True) + (root / "2025" / "a-topic").mkdir() + (root / "2025" / "notes.md").write_text("stray file in a year directory", encoding="utf-8") + (root / "2026" / "history-commands").mkdir(parents=True) + (root / "backups").mkdir() + (root / "20a6").mkdir() + (root / "²⁰²⁶").mkdir() # isdigit() is True, isascii() is not — not a year + (root / "notes.md").write_text("not a year", encoding="utf-8") + collected = collect_history_tree() + assert [year_record.year for year_record in collected] == ["2025", "2026"] + assert collected[0].topics == ["a-topic", "b-topic"] + assert collected[1].topics == ["history-commands"] + + def test_collect_history_tree_absent_root_empty(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An absent history root yields an empty list — not an error, and nothing is created.""" + monkeypatch.chdir(tmp_path) + assert collect_history_tree() == [] + assert not (tmp_path / ".goga").exists() + + def test_collect_history_tree_scoped_year_returns_single_section( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A selected year yields exactly that year's section — the other years stay out.""" + monkeypatch.chdir(tmp_path) + root = tmp_path / ".goga" / "history" + (root / "2025" / "b-topic").mkdir(parents=True) + (root / "2025" / "a-topic").mkdir() + (root / "2026" / "history-commands").mkdir(parents=True) + (root / "backups").mkdir() + (root / "notes.md").write_text("not a year", encoding="utf-8") + assert collect_history_tree(year="2025") == [HistoryYear(year="2025", topics=["a-topic", "b-topic"])] + + def test_collect_history_tree_scoped_missing_year_empty( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A selected year missing from the tree yields an empty list — not an error.""" + monkeypatch.chdir(tmp_path) + root = tmp_path / ".goga" / "history" + (root / "2026" / "feat-x").mkdir(parents=True) + assert collect_history_tree(year="2099") == [] + + def test_collect_history_tree_empty_string_is_no_selection( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``year=""`` means no selection — the full tree, identical to the no-argument call.""" + monkeypatch.chdir(tmp_path) + root = tmp_path / ".goga" / "history" + (root / "2025" / "feat-a").mkdir(parents=True) + (root / "2026" / "feat-b").mkdir(parents=True) + tree = collect_history_tree(year="") + assert [year_record.year for year_record in tree] == ["2025", "2026"] + + def test_collect_history_tree_scoped_non_four_digit_value_empty( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A selection off the year grammar matches no year directory — an empty list, not an error.""" + monkeypatch.chdir(tmp_path) + root = tmp_path / ".goga" / "history" + (root / "2026" / "feat-x").mkdir(parents=True) + assert collect_history_tree(year="20a6") == [] diff --git a/tests/hooks/__init__.py b/tests/hooks/__init__.py new file mode 100644 index 00000000..9a161256 --- /dev/null +++ b/tests/hooks/__init__.py @@ -0,0 +1 @@ +"""Tests of the hooks platform cells — ``goga/hooks``.""" diff --git a/tests/hooks/catalog/__init__.py b/tests/hooks/catalog/__init__.py new file mode 100644 index 00000000..3dbe09b6 --- /dev/null +++ b/tests/hooks/catalog/__init__.py @@ -0,0 +1 @@ +"""Tests of the action catalog cell — ``goga/hooks/catalog``.""" diff --git a/tests/hooks/catalog/test_catalog.py b/tests/hooks/catalog/test_catalog.py new file mode 100644 index 00000000..52d52ffe --- /dev/null +++ b/tests/hooks/catalog/test_catalog.py @@ -0,0 +1,110 @@ +"""Contract and logic tests for the entities declared in +``goga/hooks/catalog/CODEMANIFEST`` with ``location: catalog.py``: + +- ``Action(domain, name, error_class)`` — one catalog record, a named + subscription address with its error class +- ``declared_actions()`` — the declared action catalog, the single source of + known addresses + +Supported data only — no mocks: the catalog is maintained data, not +discovery over installed packages. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import typing + +import pytest +from goga.hooks.catalog import Action, declared_actions + +from tests.conftest import is_kw_only_dataclass + +# --- Contract tests --- + + +class TestCatalogContract: + def test_entities_are_importable_from_the_package_facade(self) -> None: + """Both entities live on the cell package and its ``__all__`` is exact.""" + import goga.hooks.catalog as cell + + assert cell.Action is Action + assert cell.declared_actions is declared_actions + assert cell.__all__ == ["Action", "declared_actions"] + + def test_action_is_a_kw_only_frozen_dataclass(self) -> None: + """``Action(domain=..., name=..., error_class=...)`` — keyword-only, frozen.""" + action = Action(domain="statuses", name="register_statuses", error_class="soft") + + assert action.domain == "statuses" + assert action.name == "register_statuses" + assert action.error_class == "soft" + + assert dataclasses.is_dataclass(Action) + assert Action.__dataclass_params__.frozen + assert is_kw_only_dataclass(Action) + + with pytest.raises(TypeError): + Action("statuses", "register_statuses", "soft") # type: ignore[misc] + + def test_action_assignment_raises_frozen_instance_error(self) -> None: + """A published record is never rewritten.""" + action = Action(domain="statuses", name="register_statuses", error_class="soft") + + with pytest.raises(dataclasses.FrozenInstanceError): + action.domain = "other" # type: ignore[misc] + + def test_action_carries_exactly_the_three_declared_fields(self) -> None: + """No computed properties, no extra state — the record is data only.""" + field_names = [field.name for field in dataclasses.fields(Action)] + + assert field_names == ["domain", "name", "error_class"] + + def test_declared_actions_signature(self) -> None: + """``declared_actions() -> list[Action]`` — no parameters.""" + parameters = inspect.signature(declared_actions).parameters + return_hint = typing.get_type_hints(declared_actions)["return"] + + assert list(parameters) == [] + assert return_hint == list[Action] + + +# --- Logic tests --- + + +class TestDeclaredActions: + def test_declared_actions_carries_the_statuses_action(self) -> None: + """The seed record — the statuses registration action, soft failures.""" + records = {(action.domain, action.name): action for action in declared_actions()} + + assert ("statuses", "register_statuses") in records + assert records[("statuses", "register_statuses")].error_class == "soft" + + def test_declared_actions_is_deterministic_and_complete(self) -> None: + """Same records in ``(domain, name)`` order on every call, unfiltered. + + A fresh list per call — mutating a returned list never reaches the + catalog constant. + """ + first = declared_actions() + second = declared_actions() + + assert first == second + assert first is not second + assert [(a.domain, a.name) for a in first] == sorted((a.domain, a.name) for a in first) + + first.clear() + + assert declared_actions() == second + assert first != second + + def test_declared_actions_records_are_well_formed(self) -> None: + """Non-empty addresses, valid error classes, unique pairs.""" + records = declared_actions() + pairs = [(action.domain, action.name) for action in records] + + assert all(action.domain for action in records) + assert all(action.name for action in records) + assert {action.error_class for action in records} <= {"soft", "hard"} + assert len(pairs) == len(set(pairs)) diff --git a/tests/hooks/conftest.py b/tests/hooks/conftest.py new file mode 100644 index 00000000..406e0fb4 --- /dev/null +++ b/tests/hooks/conftest.py @@ -0,0 +1,84 @@ +"""Shared fixtures of the hooks platform tests — the environment boundary. + +The platform reaches the outside world at exactly two points: the +installed-distributions mapping read by ``packages_distributions`` and the +``sys.modules`` entry of a ``goga_tool_*`` package. The fixtures below pin +those two points and nothing else — the platform code under test runs for +real. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable +from types import ModuleType +from typing import Any +from unittest import mock + +import pytest + +ENUMERATION_TARGET = "goga.hooks.tools.packages.packages_distributions" +"""The attribute the enumeration reads — the single enumeration mock point.""" + + +@pytest.fixture +def pin_package_environment( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[dict[str, list[str]]], mock.MagicMock]: + """Factory: pin the installed-packages mapping the enumeration reads. + + ``mapping`` carries the shape of ``packages_distributions()`` — a + top-level module name mapped to the distributions providing it. Names + without the ``goga_tool_`` prefix stay in the mapping on purpose: they + prove the filter. Returns the boundary mock, so a test can also assert + how often the environment was read. + + Args: + monkeypatch: the pytest patcher restoring the boundary on teardown. + + Returns: + The pinning factory: mapping in, boundary mock out. + """ + + def _pin(mapping: dict[str, list[str]]) -> mock.MagicMock: + boundary = mock.MagicMock(return_value=mapping) + + monkeypatch.setattr(ENUMERATION_TARGET, boundary) + + return boundary + + return _pin + + +@pytest.fixture +def install_tool_package( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[str, Callable[[Any], None] | None], ModuleType]: + """Factory: install one fake ``goga_tool_*`` package into ``sys.modules``. + + ``register_hooks`` becomes the facade callback of the package; omitting it + leaves the facade without a callback — the quiet-skip condition. Each call + installs one package and each installation is undone on teardown — one + restored ``sys.modules`` entry per fake package. + + Args: + monkeypatch: the pytest patcher restoring ``sys.modules`` on teardown. + + Returns: + The installing factory: module name in, the installed module out. + """ + + def _install( + module_name: str, + register_hooks: Callable[[Any], None] | None = None, + ) -> ModuleType: + module = ModuleType(module_name) + + if register_hooks is not None: + module.register_hooks = register_hooks + + monkeypatch.setitem(sys.modules, module_name, module) + + return module + + return _install diff --git a/tests/hooks/dispatch/__init__.py b/tests/hooks/dispatch/__init__.py new file mode 100644 index 00000000..e94168a2 --- /dev/null +++ b/tests/hooks/dispatch/__init__.py @@ -0,0 +1 @@ +"""Tests of the context mediation of the dispatch cell — ``goga/hooks/dispatch``.""" diff --git a/tests/hooks/dispatch/conftest.py b/tests/hooks/dispatch/conftest.py new file mode 100644 index 00000000..98c8a50f --- /dev/null +++ b/tests/hooks/dispatch/conftest.py @@ -0,0 +1,36 @@ +"""Fixtures of the emission tests — the catalog boundary of a hard action. + +The real catalog declares a single address, ``statuses.register_statuses``, +and it is soft. The hard failure treatment needs a hard-class record, so the +fixture below pins ``declared_actions`` in the ``goga.hooks.dispatch.emit`` +namespace — the single point the emission resolves addresses against. The +statuses record stays: within a pinned test the soft behavior keeps working +beside the hard one. +""" + +from __future__ import annotations + +import pytest +from goga.hooks.catalog import Action + +_HARD_CATALOG: list[Action] = [ + Action(domain="d", name="act", error_class="hard"), + Action(domain="statuses", name="register_statuses", error_class="soft"), +] +"""The pinned catalog — the real one plus the hard-class address ``d.act``.""" + + +@pytest.fixture +def hard_action_catalog(monkeypatch: pytest.MonkeyPatch) -> None: + """Pin the emission's catalog with one hard-class address ``d.act``. + + While pinned, the emission resolves ``("d", "act")`` as a hard action — + the first failing hook stops the sequence with a clean error — and keeps + resolving the statuses address as soft. + + Args: + monkeypatch: the pytest patcher restoring the real catalog on teardown. + """ + from goga.hooks.dispatch import emit + + monkeypatch.setattr(emit, "declared_actions", lambda: list(_HARD_CATALOG)) diff --git a/tests/hooks/dispatch/test_delivery.py b/tests/hooks/dispatch/test_delivery.py new file mode 100644 index 00000000..cbdf262c --- /dev/null +++ b/tests/hooks/dispatch/test_delivery.py @@ -0,0 +1,217 @@ +"""Contract and logic tests for the entities declared in +``goga/hooks/dispatch/CODEMANIFEST`` with ``location: delivery.py``: + +- ``wrap_context(target)`` — the transparent delivery view of an emitted + domain object: reads and calls pass through, writes are blocked +- ``build_hook_arguments(hook, context, self_context)`` — the fixed-name + injection projected against the signature of the hook + +The delivery zone owns no external boundary, so the logic tests are +mock-free: mediation is exercised over plain target objects and plain +functions. The package facade of the cell is built together with the +emission; here the entities are reached through their declared module. +""" + +from __future__ import annotations + +import importlib +import inspect +import typing + +import pytest +from goga.hooks.dispatch.delivery import build_hook_arguments, wrap_context +from goga.hooks.registry import ToolContext + + +class _Emitted: + """A stand-in emitted object: a plain attribute, a property, a method.""" + + version = "1" + + @property + def name(self) -> str: + return "n" + + def register(self, entry: str) -> tuple[str, str]: + return ("registered", entry) + + +# --- Contract tests --- + + +class TestDeliveryContract: + def test_entities_are_importable_from_the_declared_module(self) -> None: + """Both entities live on the module the CODEMANIFEST declares.""" + module = importlib.import_module("goga.hooks.dispatch.delivery") + + assert module.wrap_context is wrap_context + assert module.build_hook_arguments is build_hook_arguments + + def test_module_defines_no_public_entity_beyond_the_two(self) -> None: + """No extra API, and nothing reaches the cells beside the registry.""" + module = importlib.import_module("goga.hooks.dispatch.delivery") + defined = { + name + for name, value in vars(module).items() + if not name.startswith("_") and getattr(value, "__module__", None) == module.__name__ + } + origin_modules = {str(getattr(value, "__module__", "")) for value in vars(module).values()} + + assert defined == {"build_hook_arguments", "wrap_context"} + assert not any(origin.startswith("goga.hooks.tools") for origin in origin_modules) + assert not any(origin.startswith("goga.hooks.catalog") for origin in origin_modules) + + def test_signatures(self) -> None: + """Both functions carry exactly the declared parameters.""" + expected = { + "wrap_context": ["target"], + "build_hook_arguments": ["hook", "context", "self_context"], + } + module_globals = globals() + hints = {name: typing.get_type_hints(module_globals[name]) for name in expected} + + for name, parameters in expected.items(): + assert list(inspect.signature(module_globals[name]).parameters) == parameters + + assert hints["wrap_context"]["return"] is object + assert hints["build_hook_arguments"]["return"] == dict[str, object] + assert hints["build_hook_arguments"]["self_context"] is ToolContext + + def test_wrap_context_returns_an_object(self) -> None: + """The factory returns a proxy object — never the target itself.""" + target = object() + proxy = wrap_context(target) + + assert isinstance(proxy, object) + assert proxy is not target + + def test_the_proxy_type_carries_no_instance_state(self) -> None: + """The proxy is a slots-only closure class — no instance dict.""" + proxy = wrap_context(object()) + + assert type(proxy).__slots__ == () + assert not hasattr(proxy, "__dict__") + + +# --- Logic tests: the delivery proxy --- + + +class TestWrapContext: + def test_wrap_context_passes_reads_properties_and_calls(self) -> None: + """Reads, properties, and bound-method calls resolve on the target.""" + proxy = wrap_context(_Emitted()) + + assert proxy.version == "1" + assert proxy.name == "n" + assert proxy.register("s") == ("registered", "s") + + def test_wrap_context_blocks_assignment_and_deletion(self) -> None: + """Writes raise a clean error naming the mediation — the target stays.""" + target = type("Target", (), {"x": 1})() + proxy = wrap_context(target) + + with pytest.raises( + AttributeError, + match="the delivered context is read-only: attribute assignment is blocked", + ): + proxy.x = 2 # type: ignore[misc] + + with pytest.raises( + AttributeError, + match="the delivered context is read-only: attribute deletion is blocked", + ): + del proxy.x # type: ignore[attr-defined] + + assert target.x == 1 + assert proxy.x == 1 + + def test_wrap_context_exposes_no_reference_to_target(self) -> None: + """The proxy hides the target — no dict, no class identity, no alias.""" + target = type("Target", (), {"tool_name": "demo"})() + proxy = wrap_context(target) + + # CPython answers vars() of an instance without __dict__ with a + # TypeError; the checked fact is that no proxy dict exists to mine. + with pytest.raises(TypeError): + vars(proxy) # type: ignore[arg-type] + + with pytest.raises(AttributeError): + _ = proxy.__dict__ # type: ignore[attr-defined] + + assert proxy.__class__ is not type(target) + assert hasattr(proxy, "tool_name") is True + assert [name for name in dir(proxy) if not name.startswith("__")] == [] + assert target not in [getattr(proxy, name) for name in dir(proxy)] + + def test_wrap_context_dunder_lookup_never_reaches_target(self) -> None: + """Special-method lookup follows the language default, not the target.""" + proxy = wrap_context(type("Sized", (), {"__len__": lambda _self: 5})()) + + with pytest.raises(TypeError): + len(proxy) # type: ignore[arg-type] + + with pytest.raises(AttributeError): + _ = proxy.__len__ # type: ignore[attr-defined] + + def test_the_proxy_class_is_fresh_per_delivery(self) -> None: + """Every call builds its own class in the closure — no shared type.""" + target = object() + + assert type(wrap_context(target)) is not type(wrap_context(target)) + + +# --- Logic tests: the signature projection --- + + +class TestBuildHookArguments: + def test_build_hook_arguments_fills_declared_names_in_any_order(self) -> None: + """Values land by declared name; unoffered and positional-only get nothing.""" + context_view = object() + own = ToolContext(tool="demo") + + def takes_context(context: object) -> None: + """Declares only the delivered view.""" + + def takes_self_first(self: ToolContext, context: object) -> None: + """Declares both — the tool context first.""" + + def takes_context_first(context: object, self: ToolContext) -> None: + """Declares both — the delivered view first.""" + + def takes_keyword_only(*, context: object) -> None: + """Declares the delivered view as keyword-only.""" + + def takes_other(other: object) -> None: + """Declares an unoffered name.""" + + def takes_positional_only(context: object, /) -> None: + """Declares the delivered view as positional-only — no injection.""" + + def takes_mixed(first: object, /, context: object) -> None: + """Only the name decides: a keyword-capable context still receives.""" + + assert build_hook_arguments(takes_context, context_view, own) == {"context": context_view} + assert build_hook_arguments(takes_self_first, context_view, own) == { + "context": context_view, + "self": own, + } + assert build_hook_arguments(takes_context_first, context_view, own) == { + "context": context_view, + "self": own, + } + assert build_hook_arguments(takes_keyword_only, context_view, own) == {"context": context_view} + assert build_hook_arguments(takes_other, context_view, own) == {} + assert build_hook_arguments(takes_positional_only, context_view, own) == {} + assert build_hook_arguments(takes_mixed, context_view, own) == {"context": context_view} + + def test_build_hook_arguments_never_calls_the_hook(self) -> None: + """The projection is a read of the signature — the call is not here.""" + calls: list[object] = [] + + def hook(context: object, self: ToolContext) -> None: + calls.append(context) + + arguments = build_hook_arguments(hook, object(), ToolContext(tool="t")) + + assert calls == [] + assert set(arguments) == {"context", "self"} diff --git a/tests/hooks/dispatch/test_emit.py b/tests/hooks/dispatch/test_emit.py new file mode 100644 index 00000000..06415b18 --- /dev/null +++ b/tests/hooks/dispatch/test_emit.py @@ -0,0 +1,364 @@ +"""Contract and logic tests for the entity declared in +``goga/hooks/dispatch/CODEMANIFEST`` with ``location: emit.py``: + +- ``emit_hook_event(registry, domain, action, context_for)`` — the emission + of an action to its subscribed hooks under the action's error class + +The environment boundary is pinned by the shared fixtures of +``tests/hooks/conftest.py`` — the enumeration mapping and the fake +``goga_tool_*`` modules — so the registry, the registrars, and the delivery +run for real behind the emission. The hard failure treatment needs a +hard-class catalog record; ``tests/hooks/dispatch/conftest.py`` pins the +catalog of the emission for that, and the registration-side catalog is +pinned the same way in the hard-failure test below, so the hard address +registers through the real envelope. +""" + +from __future__ import annotations + +import importlib +import inspect +import typing +from collections.abc import Callable +from unittest import mock + +import pytest +from goga.hooks.catalog import Action +from goga.hooks.dispatch import emit_hook_event +from goga.hooks.dispatch.delivery import wrap_context +from goga.hooks.registry import HookRegistry, ToolContext +from goga.hooks.tools import registration + +_CELL_ALL = ["build_hook_arguments", "emit_hook_event", "wrap_context"] + + +def _noop_hook(context: object) -> None: + """A stand-in registered callable — receiving the view is all it does.""" + + +def _plain_view(tool: str) -> object: + """A stand-in view builder — the view itself carries no meaning here.""" + return object() + + +def _subscribe( + name: str, + hook: Callable[..., object], + domain: str = "statuses", + action: str = "register_statuses", +) -> Callable[[object], None]: + """Build a facade callback subscribing ``hook`` under ``name`` on an address.""" + + def register_hooks(hooks: object) -> None: + hooks.subscribe(domain, action, name, hook) # type: ignore[attr-defined] + + return register_hooks + + +# --- Contract tests --- + + +class TestEmissionContract: + def test_entity_is_importable_from_the_package_facade(self) -> None: + """The emission lives on the cell package and its ``__all__`` is exact.""" + import goga.hooks.dispatch as cell + from goga.hooks.dispatch.delivery import build_hook_arguments + + assert cell.emit_hook_event is emit_hook_event + assert cell.build_hook_arguments is build_hook_arguments + assert cell.wrap_context is wrap_context + assert cell.__all__ == _CELL_ALL + + def test_module_defines_no_public_entity_beyond_the_one(self) -> None: + """No extra API, and the cell imports from catalog and registry only.""" + module = importlib.import_module("goga.hooks.dispatch.emit") + defined = { + name + for name, value in vars(module).items() + if not name.startswith("_") and getattr(value, "__module__", None) == module.__name__ + } + origin_modules = {str(getattr(value, "__module__", "")) for value in vars(module).values()} + platform_origins = { + origin for origin in origin_modules if origin.startswith("goga.hooks.") and origin != module.__name__ + } + + assert defined == {"emit_hook_event"} + assert platform_origins <= { + "goga.hooks.catalog", + "goga.hooks.catalog.catalog", + "goga.hooks.registry", + "goga.hooks.registry.state", + "goga.hooks.dispatch.delivery", + } + + def test_signature_and_return(self) -> None: + """The emission carries exactly the declared parameters and returns None.""" + assert list(inspect.signature(emit_hook_event).parameters) == [ + "registry", + "domain", + "action", + "context_for", + ] + + hints = typing.get_type_hints(emit_hook_event) + + assert hints["return"] is type(None) + assert hints["registry"] is HookRegistry + assert hints["domain"] is str + assert hints["action"] is str + + def test_the_first_emission_performs_the_single_build( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """The emission assembles the registry — no separate build step.""" + boundary = pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + install_tool_package("goga_tool_a", register_hooks=_subscribe("one", _noop_hook)) + registry = HookRegistry() + + assert registry.subscriptions == [] + + emit_hook_event(registry, "statuses", "register_statuses", _plain_view) + + assert boundary.call_count == 1 + assert [s.name for s in registry.subscriptions] == ["one"] + + def test_a_second_emission_never_rebuilds_the_registry( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """Two emissions over one registry enumerate the environment once.""" + boundary = pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + calls: list[object] = [] + + def hook(context: object) -> None: + calls.append(context) + + install_tool_package("goga_tool_a", register_hooks=_subscribe("one", hook)) + registry = HookRegistry() + + emit_hook_event(registry, "statuses", "register_statuses", _plain_view) + emit_hook_event(registry, "statuses", "register_statuses", _plain_view) + + assert boundary.call_count == 1 + assert len(calls) == 2 # the hook fires again; the build does not + + +# --- Logic tests: the delivered event --- + + +class TestEmissionDelivery: + def test_emit_delivers_proxied_context_and_self_to_hook( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """The hook receives the delivery view and its own tool context.""" + pin_package_environment({"goga_tool_demo": ["goga-tool-demo"]}) + captured: list[tuple[object, ToolContext]] = [] + + def hook(self: ToolContext, context: object) -> None: + captured.append((context, self)) + + install_tool_package("goga_tool_demo", register_hooks=_subscribe("published", hook)) + sentinel = type("Sentinel", (), {"tool_name": "demo-tool-name", "marker": 1})() + registry = HookRegistry() + + def context_for(tool: str) -> object: + return sentinel + + emit_hook_event(registry, "statuses", "register_statuses", context_for) + + assert len(captured) == 1 + captured_context, captured_self = captured[0] + + assert captured_context is not sentinel + assert captured_context.tool_name == sentinel.tool_name # reads pass through + + with pytest.raises(AttributeError, match="read-only"): + captured_context.marker = 2 # type: ignore[misc] + + assert sentinel.marker == 1 # the emitted object stays untouched + assert isinstance(captured_self, ToolContext) + assert captured_self.tool == "demo" + assert captured_self is registry.self_context("demo") + + # The proxy class is created in a closure per delivery — an isinstance + # check against any other proxy type can never succeed. + assert not isinstance(captured_context, type(wrap_context(sentinel))) + + def test_emit_builds_one_view_per_distinct_tool( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """One context view per distinct tool, reused by its remaining hooks.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) + built_for: list[str] = [] + received: dict[str, list[object]] = {} + + def context_for(tool: str) -> object: + built_for.append(tool) + return type("View", (), {"token": object()})() # a fresh token per built view + + def hook_of(label: str) -> Callable[..., None]: + def hook(context: object) -> None: + received.setdefault(label, []).append(context) + + return hook + + def register_a(hooks: object) -> None: + subscribe = hooks.subscribe # type: ignore[attr-defined] + subscribe("statuses", "register_statuses", "one", hook_of("one")) + subscribe("statuses", "register_statuses", "two", hook_of("two")) + + def register_b(hooks: object) -> None: + hooks.subscribe("statuses", "register_statuses", "three", hook_of("three")) # type: ignore[attr-defined] + + install_tool_package("goga_tool_a", register_hooks=register_a) + install_tool_package("goga_tool_b", register_hooks=register_b) + registry = HookRegistry() + + emit_hook_event(registry, "statuses", "register_statuses", context_for) + + assert built_for == ["a", "b"] # one per distinct tool, first-subscription order + assert set(received) == {"one", "two", "three"} + + # Both hooks of "a" read the same underlying view — the proxies differ, + # the delivered object does not. + assert received["one"][0].token is received["two"][0].token # type: ignore[attr-defined] + assert received["one"][0].token is not received["three"][0].token # type: ignore[attr-defined] + + +# --- Logic tests: failures --- + + +class TestEmissionFailures: + def test_emit_unknown_address_raises_clean_error( + self, + pin_package_environment, + ) -> None: + """An address outside the catalog is a clean error of the emitting side.""" + pin_package_environment({}) + registry = HookRegistry() + + with pytest.raises(ValueError, match=r"unknown hook action: statuses\.no_such_action"): + emit_hook_event(registry, "statuses", "no_such_action", _plain_view) + + def test_emit_soft_failure_warns_and_continues( + self, + pin_package_environment, + install_tool_package, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A soft hook is skipped with a warning — the sequence continues.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) + b_calls: list[object] = [] + + def failing(context: object) -> None: + raise ValueError("bad registration") + + def b_hook(context: object) -> None: + b_calls.append(context) + + install_tool_package("goga_tool_a", register_hooks=_subscribe("x", failing)) + install_tool_package("goga_tool_b", register_hooks=_subscribe("y", b_hook)) + registry = HookRegistry() + + emit_hook_event(registry, "statuses", "register_statuses", _plain_view) + + assert len(b_calls) == 1 + assert "hook x of tool a failed on statuses.register_statuses: bad registration" in caplog.text + + def test_emit_hard_failure_stops_at_first_failure( + self, + pin_package_environment, + install_tool_package, + monkeypatch: pytest.MonkeyPatch, + hard_action_catalog: object, + ) -> None: + """A hard hook failure stops the sequence with a clean error.""" + calls: list[str] = [] + + def first(context: object) -> None: + calls.append("first") + raise RuntimeError("stop") + + def second(context: object) -> None: + calls.append("second") + + monkeypatch.setattr( + registration, + "declared_actions", + lambda: [ + Action(domain="d", name="act", error_class="hard"), + Action(domain="statuses", name="register_statuses", error_class="soft"), + ], + ) + pin_package_environment({"goga_tool_t1": ["goga-tool-t1"], "goga_tool_t2": ["goga-tool-t2"]}) + install_tool_package("goga_tool_t1", register_hooks=_subscribe("n1", first, domain="d", action="act")) + install_tool_package("goga_tool_t2", register_hooks=_subscribe("n2", second, domain="d", action="act")) + registry = HookRegistry() + + with pytest.raises(ValueError, match=r"hook n1 of tool t1 failed on d\.act: stop"): + emit_hook_event(registry, "d", "act", _plain_view) + + assert calls == ["first"] + + def test_emit_context_for_failure_is_clean_error_not_hook_failure( + self, + pin_package_environment, + install_tool_package, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A crashing view builder is an emitting-side error, never a warning.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + hook_calls: list[object] = [] + + def hook(context: object) -> None: + hook_calls.append(context) + + install_tool_package("goga_tool_a", register_hooks=_subscribe("x", hook)) + registry = HookRegistry() + + def context_for(tool: str) -> object: + return 1 / 0 # a crash of the emitting side, not of the hook + + with pytest.raises(ZeroDivisionError): + emit_hook_event(registry, "statuses", "register_statuses", context_for) + + assert hook_calls == [] + assert "failed on statuses.register_statuses" not in caplog.text + + def test_emit_projection_failure_is_treated_as_hook_failure( + self, + pin_package_environment, + install_tool_package, + caplog: pytest.LogCaptureFixture, + ) -> None: + """An unprojectable signature is a hook failure under the error class.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + install_tool_package("goga_tool_a", register_hooks=_subscribe("built-in", dict)) + registry = HookRegistry() + + emit_hook_event(registry, "statuses", "register_statuses", _plain_view) + + assert "hook built-in of tool a failed on statuses.register_statuses" in caplog.text + + def test_emit_address_without_submissions_emits_nothing( + self, + pin_package_environment, + caplog: pytest.LogCaptureFixture, + ) -> None: + """No subscriptions of the address — no view, no call, no diagnostics.""" + pin_package_environment({}) + registry = HookRegistry() + context_for = mock.Mock() + + result = emit_hook_event(registry, "statuses", "register_statuses", context_for) + + assert result is None + context_for.assert_not_called() + assert caplog.text == "" diff --git a/tests/hooks/registry/__init__.py b/tests/hooks/registry/__init__.py new file mode 100644 index 00000000..846b9c31 --- /dev/null +++ b/tests/hooks/registry/__init__.py @@ -0,0 +1 @@ +"""Tests of the run registry cell — ``goga/hooks/registry``.""" diff --git a/tests/hooks/registry/test_state.py b/tests/hooks/registry/test_state.py new file mode 100644 index 00000000..563b4cbb --- /dev/null +++ b/tests/hooks/registry/test_state.py @@ -0,0 +1,422 @@ +"""Contract and logic tests for the entities declared in +``goga/hooks/registry/CODEMANIFEST`` with ``location: state.py``: + +- ``HookRegistry()`` — the run registry: the single assembly, the read side, + the isolated per-tool contexts, and the per-tool inspection view +- ``ToolContext(tool)`` — the isolated runtime context of one tool +- ``ToolHooks(tool, subscriptions, rejections)`` — the per-tool inspection + entry + +The environment boundary is pinned by the shared fixtures of +``tests/hooks/conftest.py`` — the enumeration mapping and the fake +``goga_tool_*`` modules. The registry, its registrars, and the package access +run for real; the fatal broken-import case is mocked at the +``call_register_hooks`` seam in one test and driven through a real broken +package on disk in another. +""" + +from __future__ import annotations + +import dataclasses +import importlib +import inspect +import typing +from collections.abc import Callable +from pathlib import Path + +import pytest +from goga.hooks.registry import HookRegistry, ToolContext, ToolHooks +from goga.hooks.tools import RejectedRegistration, Subscription + +from tests.conftest import is_kw_only_dataclass + +_CELL_ALL = ["HookRegistry", "ToolContext", "ToolHooks"] + + +def _noop_hook(context: object) -> None: + """A stand-in registered callable — the registry never calls it.""" + + +def _subscribe(name: str) -> Callable[[object], None]: + """Build a facade callback subscribing one hook under ``name``.""" + + def register_hooks(hooks: object) -> None: + hooks.subscribe("statuses", "register_statuses", name, _noop_hook) # type: ignore[attr-defined] + + return register_hooks + + +# --- Contract tests --- + + +class TestRegistryContract: + def test_entities_are_importable_from_the_package_facade(self) -> None: + """All three entities live on the cell package and its ``__all__`` is exact.""" + import goga.hooks.registry as cell + + assert cell.HookRegistry is HookRegistry + assert cell.ToolContext is ToolContext + assert cell.ToolHooks is ToolHooks + assert cell.__all__ == _CELL_ALL + + for name in _CELL_ALL: + assert getattr(cell, name, None) is not None + + def test_module_defines_no_public_entity_beyond_the_three(self) -> None: + """No extra API, and nothing reaches the cells above the registry.""" + module = importlib.import_module("goga.hooks.registry.state") + defined = { + name + for name, value in vars(module).items() + if not name.startswith("_") and getattr(value, "__module__", None) == module.__name__ + } + origin_modules = {str(getattr(value, "__module__", "")) for value in vars(module).values()} + + assert defined == {"HookRegistry", "ToolContext", "ToolHooks"} + assert not any(origin.startswith("goga.hooks.dispatch") for origin in origin_modules) + assert not any(origin.startswith("goga.hooks.catalog") for origin in origin_modules) + + def test_hook_registry_constructs_with_no_arguments(self) -> None: + """``HookRegistry()`` — keyword-only dataclass, private build state.""" + registry = HookRegistry() + + assert isinstance(registry, HookRegistry) + assert dataclasses.is_dataclass(HookRegistry) + assert is_kw_only_dataclass(HookRegistry) + assert not HookRegistry.__dataclass_params__.frozen + + assert [(f.name, f.init, f.repr) for f in dataclasses.fields(HookRegistry)] == [ + ("_built", False, False), + ("_subscriptions", False, False), + ("_rejections", False, False), + ("_contexts", False, False), + ] + + with pytest.raises(TypeError): + HookRegistry("anything") # type: ignore[misc] + + def test_method_signatures(self) -> None: + """The four methods carry exactly the declared parameters.""" + expected = { + "build_once": ["self"], + "subscriptions_for": ["self", "domain", "action"], + "self_context": ["self", "tool"], + "by_tool": ["self"], + } + return_hints = {name: typing.get_type_hints(getattr(HookRegistry, name))["return"] for name in expected} + + for name, parameters in expected.items(): + assert list(inspect.signature(getattr(HookRegistry, name)).parameters) == parameters + + assert return_hints["build_once"] is type(None) + assert return_hints["subscriptions_for"] == list[Subscription] + assert return_hints["self_context"] is ToolContext + assert return_hints["by_tool"] == list[ToolHooks] + + def test_reads_are_lists_and_start_empty(self) -> None: + """``subscriptions`` and ``rejections`` are readable lists before the build.""" + registry = HookRegistry() + + assert isinstance(registry.subscriptions, list) + assert isinstance(registry.rejections, list) + assert registry.subscriptions == [] + assert registry.rejections == [] + + def test_construction_enumerates_nothing(self, pin_package_environment) -> None: + """A fresh registry is cheap — the environment is not read at construction.""" + boundary = pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + + HookRegistry() + + boundary.assert_not_called() + + def test_reads_never_trigger_the_build(self, pin_package_environment) -> None: + """The read properties are pure reads — no enumeration behind them.""" + boundary = pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + registry = HookRegistry() + + assert registry.subscriptions == [] + assert registry.rejections == [] + assert registry.subscriptions_for("statuses", "register_statuses") == [] + assert registry.by_tool() == [] + + boundary.assert_not_called() + + def test_tool_context_is_kw_only_mutable_and_open(self) -> None: + """``ToolContext(tool=...)`` — keyword-only, mutable, no ``__slots__``.""" + context = ToolContext(tool="t") + + assert context.tool == "t" + assert dataclasses.is_dataclass(ToolContext) + assert is_kw_only_dataclass(ToolContext) + assert not ToolContext.__dataclass_params__.frozen + assert [f.name for f in dataclasses.fields(ToolContext)] == ["tool"] + + context.own_state = {"runs": 1} # a hook writes its own context freely + + assert context.own_state == {"runs": 1} + assert not hasattr(ToolContext, "__slots__") + + with pytest.raises(TypeError): + ToolContext("t") # type: ignore[misc] + + def test_tool_hooks_is_a_kw_only_frozen_record(self) -> None: + """``ToolHooks`` carries exactly the three declared fields, frozen.""" + subscription = Subscription( + tool="t", + domain="statuses", + action="register_statuses", + name="published", + hook=_noop_hook, + ) + rejection = RejectedRegistration( + tool="t", + domain="statuses", + action="register_statuses", + name="dup", + reason="repeated name on the same address", + ) + entry = ToolHooks(tool="t", subscriptions=[subscription], rejections=[rejection]) + + assert entry.tool == "t" + assert entry.subscriptions == [subscription] + assert entry.rejections == [rejection] + + assert dataclasses.is_dataclass(ToolHooks) + assert ToolHooks.__dataclass_params__.frozen + assert is_kw_only_dataclass(ToolHooks) + assert [f.name for f in dataclasses.fields(ToolHooks)] == [ + "tool", + "subscriptions", + "rejections", + ] + + with pytest.raises(TypeError): + ToolHooks("t", [], []) # type: ignore[misc] + + with pytest.raises(dataclasses.FrozenInstanceError): + entry.tool = "other" # type: ignore[misc] + + +# --- Logic tests: the single build --- + + +class TestBuildOnce: + def test_build_once_collects_in_enumeration_order( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """Subscriptions land in enumeration order, qualified by package identity.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) + install_tool_package("goga_tool_a", register_hooks=_subscribe("one")) + install_tool_package("goga_tool_b", register_hooks=_subscribe("two")) + registry = HookRegistry() + + registry.build_once() + + assert [(s.tool, s.name) for s in registry.subscriptions] == [("a", "one"), ("b", "two")] + + def test_build_once_is_idempotent( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """Two builds over one registry enumerate once and never double the state.""" + boundary = pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + install_tool_package("goga_tool_a", register_hooks=_subscribe("one")) + registry = HookRegistry() + + registry.build_once() + registry.build_once() + + assert boundary.call_count == 1 + assert [s.name for s in registry.subscriptions] == ["one"] + + def test_build_once_skips_a_package_without_the_callback_quietly( + self, + pin_package_environment, + install_tool_package, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A facade without ``register_hooks`` is a quiet skip — no warning.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) + install_tool_package("goga_tool_a") # no callback on the facade + install_tool_package("goga_tool_b", register_hooks=_subscribe("two")) + registry = HookRegistry() + + registry.build_once() + + assert [s.name for s in registry.subscriptions] == ["two"] + assert caplog.text == "" + + def test_build_once_collects_rejections_of_every_registrar( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """Refused envelopes are part of the assembled state, in order.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + + def register_hooks(hooks: object) -> None: + subscribe = hooks.subscribe # type: ignore[attr-defined] + subscribe("statuses", "register_statuses", "ok", _noop_hook) + subscribe("statuses", "register_statuses", "ok", _noop_hook) # repeated + subscribe("statuses", "no_such_action", "lost", _noop_hook) # unknown address + + install_tool_package("goga_tool_a", register_hooks=register_hooks) + registry = HookRegistry() + + registry.build_once() + + assert [r.reason for r in registry.rejections] == [ + "repeated name on the same address", + "unknown action statuses.no_such_action", + ] + assert [r.tool for r in registry.rejections] == ["a", "a"] + + def test_build_once_callback_crash_warns_and_keeps_partial_registrations( + self, + pin_package_environment, + install_tool_package, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A crashed callback ends its own registration only — the rest runs.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) + + def crashing(hooks: object) -> None: + hooks.subscribe("statuses", "register_statuses", "first", _noop_hook) # type: ignore[attr-defined] + raise RuntimeError("kaput") + + install_tool_package("goga_tool_a", register_hooks=crashing) + install_tool_package("goga_tool_b", register_hooks=_subscribe("second")) + registry = HookRegistry() + + registry.build_once() + + assert [s.name for s in registry.subscriptions] == ["first", "second"] + assert "skipping hook registration of tool a: kaput" in caplog.text + + def test_build_once_broken_import_is_fatal_through_the_real_import( + self, + pin_package_environment, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The real import boundary and the real build agree on the fatal case. + + A facade importing a missing dependency fails through the platform's + own wrapper, and the build re-raises it: the producer + (``call_register_hooks``) and the consumer (``build_once``) run + together here, so the fatal case does not rest on a hand-crafted + message at the ``call_register_hooks`` seam. + """ + package_dir = tmp_path / "goga_tool_broken" + package_dir.mkdir() + (package_dir / "__init__.py").write_text("import goga_missing_dependency\n") + monkeypatch.syspath_prepend(tmp_path) + pin_package_environment({"goga_tool_broken": ["goga-tool-broken"]}) + registry = HookRegistry() + + with pytest.raises(ImportError, match=r"package goga_tool_broken failed to import"): + registry.build_once() + + def test_build_once_callback_importerror_is_warning_not_fatal( + self, + pin_package_environment, + install_tool_package, + caplog: pytest.LogCaptureFixture, + ) -> None: + """An import failure raised inside a callback is a crash, not the fatal case.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) + + def crashing(hooks: object) -> None: + hooks.subscribe("statuses", "register_statuses", "first", _noop_hook) # type: ignore[attr-defined] + raise ModuleNotFoundError("No module named 'opt_dep'", name="opt_dep") + + install_tool_package("goga_tool_a", register_hooks=crashing) + install_tool_package("goga_tool_b", register_hooks=_subscribe("second")) + registry = HookRegistry() + + registry.build_once() + + assert [s.name for s in registry.subscriptions] == ["first", "second"] + assert "skipping hook registration of tool a" in caplog.text + + +# --- Logic tests: the read side --- + + +class TestRegistryReads: + def test_subscriptions_for_returns_the_address_subscriptions_in_order( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """Exact address match, enumeration order.""" + pin_package_environment({"goga_tool_a": ["goga-tool-a"], "goga_tool_b": ["goga-tool-b"]}) + install_tool_package("goga_tool_a", register_hooks=_subscribe("one")) + install_tool_package("goga_tool_b", register_hooks=_subscribe("two")) + registry = HookRegistry() + + registry.build_once() + + assert [s.name for s in registry.subscriptions_for("statuses", "register_statuses")] == ["one", "two"] + assert [s.name for s in registry.subscriptions_for("statuses", "register_other")] == [] + + def test_subscriptions_for_empty_address_is_not_an_error( + self, + pin_package_environment, + ) -> None: + """An address without subscriptions yields an empty list.""" + pin_package_environment({}) + registry = HookRegistry() + + registry.build_once() + + assert registry.subscriptions_for("statuses", "register_statuses") == [] + assert registry.subscriptions_for("nope", "no_action") == [] + + def test_self_context_returns_one_instance_per_tool(self) -> None: + """One context per tool per run — the tool's invocations share it.""" + registry = HookRegistry() + + first = registry.self_context("a") + second = registry.self_context("a") + other = registry.self_context("b") + + assert first is second + assert first is not other + assert first.tool == "a" + assert other.tool == "b" + + def test_by_tool_groups_alphabetically_with_rejections( + self, + pin_package_environment, + install_tool_package, + ) -> None: + """One entry per tool, alphabetical — both lists on the same entry.""" + pin_package_environment({"goga_tool_b": ["goga-tool-b"], "goga_tool_a": ["goga-tool-a"]}) + install_tool_package("goga_tool_b", register_hooks=_subscribe("kept")) + + def refused(hooks: object) -> None: + subscribe = hooks.subscribe # type: ignore[attr-defined] + subscribe("statuses", "register_statuses", "kept", _noop_hook) + subscribe("statuses", "register_statuses", "dup", "not-callable") + + install_tool_package("goga_tool_a", register_hooks=refused) + registry = HookRegistry() + + registry.build_once() + + view = registry.by_tool() + + assert [entry.tool for entry in view] == ["a", "b"] + assert [s.name for s in view[0].subscriptions] == ["kept"] + assert [r.name for r in view[0].rejections] == ["dup"] + assert [s.name for s in view[1].subscriptions] == ["kept"] + assert view[1].rejections == [] + + def test_by_tool_of_an_empty_registry_is_empty(self) -> None: + """A registry without registrations carries no entry at all.""" + assert HookRegistry().by_tool() == [] diff --git a/tests/hooks/test_facade.py b/tests/hooks/test_facade.py new file mode 100644 index 00000000..1fd148b0 --- /dev/null +++ b/tests/hooks/test_facade.py @@ -0,0 +1,55 @@ +"""Contract and logic tests for the cell declared in +``goga/hooks/CODEMANIFEST`` — the facade of the hooks platform. + +The facade declares no type of its own: it re-exports the four embeddings +consumers address the platform through — ``declared_actions``, +``HookRegistry``, ``ToolHooks``, and ``emit_hook_event`` — each identical to +the object its subcell package owns. The facade import is cheap: reloading +the package reads no installed-distribution mapping and builds no registry. +""" + +from __future__ import annotations + +import importlib + +import goga.hooks +from goga.hooks import catalog as catalog_source +from goga.hooks import dispatch as dispatch_source +from goga.hooks import registry as registry_source + +_FACADE_ALL = ["HookRegistry", "ToolHooks", "declared_actions", "emit_hook_event"] + + +class TestHooksPlatformFacade: + def test_all_lists_exactly_the_four_reexports(self) -> None: + """The facade declares exactly the four embeddings, alphabetically.""" + assert goga.hooks.__all__ == _FACADE_ALL + + def test_declared_actions_is_the_catalog_object(self) -> None: + """declared_actions is the catalog routine, not a copy of it.""" + assert goga.hooks.declared_actions is catalog_source.declared_actions + + def test_hook_registry_is_the_registry_object(self) -> None: + """HookRegistry is the registry class, not a copy of it.""" + assert goga.hooks.HookRegistry is registry_source.HookRegistry + + def test_tool_hooks_is_the_registry_object(self) -> None: + """ToolHooks is the registry record, not a copy of it.""" + assert goga.hooks.ToolHooks is registry_source.ToolHooks + + def test_emit_hook_event_is_the_dispatch_object(self) -> None: + """emit_hook_event is the emission routine, not a copy of it.""" + assert goga.hooks.emit_hook_event is dispatch_source.emit_hook_event + + def test_every_declared_name_is_importable(self) -> None: + """Each name of ``__all__`` resolves to a real attribute of the facade.""" + for name in goga.hooks.__all__: + assert getattr(goga.hooks, name) is not None + + def test_importing_the_facade_enumerates_no_packages(self, pin_package_environment) -> None: + """The facade import reads no installed-distribution mapping.""" + boundary = pin_package_environment({}) + + importlib.reload(goga.hooks) + + boundary.assert_not_called() diff --git a/tests/hooks/tools/__init__.py b/tests/hooks/tools/__init__.py new file mode 100644 index 00000000..eaa4a8d8 --- /dev/null +++ b/tests/hooks/tools/__init__.py @@ -0,0 +1 @@ +"""Tests of the tool-package access cell — ``goga/hooks/tools``.""" diff --git a/tests/hooks/tools/test_packages.py b/tests/hooks/tools/test_packages.py new file mode 100644 index 00000000..47365dd9 --- /dev/null +++ b/tests/hooks/tools/test_packages.py @@ -0,0 +1,234 @@ +"""Contract and logic tests for the entities declared in +``goga/hooks/tools/CODEMANIFEST`` with ``location: packages.py``: + +- ``ToolPackage(module_name)`` — the identity of one installed tool package +- ``enumerate_tool_packages()`` — the deterministic environment enumeration +- ``call_register_hooks(package, registrar)`` — the facade import with the + invocation of the single registration callback + +The environment boundary is pinned by the shared fixtures of +``tests/hooks/conftest.py`` and by ``mock.patch`` on ``import_module``; the +module under test runs for real. +""" + +from __future__ import annotations + +import dataclasses +import importlib +import inspect +import typing +from types import ModuleType +from unittest import mock + +import pytest +from goga.hooks.tools import packages +from goga.hooks.tools.packages import ( + ToolPackage, + call_register_hooks, + enumerate_tool_packages, +) + +from tests.conftest import is_kw_only_dataclass + +# --- Contract tests --- + + +class TestPackagesContract: + def test_entities_are_importable_from_the_module(self) -> None: + """The three entities live on ``packages.py`` — the cell's only code here.""" + assert packages.ToolPackage is ToolPackage + assert packages.enumerate_tool_packages is enumerate_tool_packages + assert packages.call_register_hooks is call_register_hooks + + def test_module_defines_no_public_entity_beyond_the_three(self) -> None: + """No extra API, and nothing is pulled in from the catalog cell.""" + module = importlib.import_module("goga.hooks.tools.packages") + defined = { + name + for name, value in vars(module).items() + if not name.startswith("_") and getattr(value, "__module__", None) == module.__name__ + } + origin_modules = {str(getattr(value, "__module__", "")) for value in vars(module).values()} + + assert defined == {"ToolPackage", "enumerate_tool_packages", "call_register_hooks"} + assert not any(origin.startswith("goga.hooks.catalog") for origin in origin_modules) + + def test_tool_package_is_kw_only_frozen_with_computed_properties(self) -> None: + """``ToolPackage(module_name=...)`` — keyword-only, frozen, one field. + + ``tool`` and ``facade`` are computed properties, not fields: the + record stores the pip name and derives the identity from it. + """ + package = ToolPackage(module_name="goga_tool_x") + + assert dataclasses.is_dataclass(ToolPackage) + assert ToolPackage.__dataclass_params__.frozen + assert is_kw_only_dataclass(ToolPackage) + assert package.module_name == "goga_tool_x" + + assert [field.name for field in dataclasses.fields(ToolPackage)] == ["module_name"] + + with pytest.raises(TypeError): + ToolPackage("goga_tool_x") # type: ignore[misc] + + with pytest.raises(dataclasses.FrozenInstanceError): + package.module_name = "goga_tool_other" # type: ignore[misc] + + def test_enumerate_tool_packages_signature(self) -> None: + """``enumerate_tool_packages() -> list[ToolPackage]`` — no parameters.""" + parameters = inspect.signature(enumerate_tool_packages).parameters + return_hint = typing.get_type_hints(enumerate_tool_packages)["return"] + + assert list(parameters) == [] + assert return_hint == list[ToolPackage] + + def test_call_register_hooks_signature(self) -> None: + """``call_register_hooks(package, registrar) -> bool`` — two parameters.""" + signature = inspect.signature(call_register_hooks) + + assert list(signature.parameters) == ["package", "registrar"] + assert signature.return_annotation == "bool" + + +# --- Logic tests: the identity and the enumeration --- + + +class TestToolPackageIdentity: + def test_tool_package_identity_is_canonical_hyphen_form(self) -> None: + """The prefix is dropped, underscores become hyphens; facade verbatim.""" + package = ToolPackage(module_name="goga_tool_my_tool") + + assert package.tool == "my-tool" + assert package.facade == "goga_tool_my_tool" + + assert ToolPackage(module_name="goga_tool_mkdocs").tool == "mkdocs" + + +class TestEnumerateToolPackages: + def test_enumerate_tool_packages_filters_prefix_and_sorts(self, pin_package_environment) -> None: + """Only ``goga_tool_*`` names, alphabetically — and no import happens.""" + pin_package_environment( + { + "other_pkg": ["other-pkg"], + "goga_tool_b": ["goga-tool-b"], + "goga_tool_a": ["goga-tool-a"], + } + ) + + with mock.patch.object(packages, "import_module") as import_mock: + result = enumerate_tool_packages() + + import_mock.assert_not_called() + + assert [package.module_name for package in result] == ["goga_tool_a", "goga_tool_b"] + + def test_enumerate_tool_packages_empty_environment(self, pin_package_environment) -> None: + """An environment without tool packages yields an empty list, not an error.""" + pin_package_environment({}) + + assert enumerate_tool_packages() == [] + + def test_enumerate_tool_packages_reads_the_boundary_once_per_call( + self, + pin_package_environment, + ) -> None: + """One environment read per call — the enumeration is a single pass.""" + boundary = pin_package_environment({"goga_tool_a": ["goga-tool-a"]}) + + first = enumerate_tool_packages() + second = enumerate_tool_packages() + + assert first == second + assert boundary.call_count == 2 + + +# --- Logic tests: the facade callback invocation --- + + +class TestCallRegisterHooks: + def test_call_register_hooks_invokes_callback_with_single_registrar( + self, + install_tool_package, + ) -> None: + """The callback runs with the registrar as its single argument.""" + captured: list[object] = [] + install_tool_package("goga_tool_demo", register_hooks=captured.append) + + registrar = object() + + assert call_register_hooks(ToolPackage(module_name="goga_tool_demo"), registrar) is True + assert captured == [registrar] + + def test_call_register_hooks_missing_package_is_quiet_skip(self, capsys) -> None: + """The metadata names a package whose module is absent — a silent False.""" + absent = ToolPackage(module_name="goga_tool_absent_from_the_env") + + assert call_register_hooks(absent, object()) is False + assert capsys.readouterr().err == "" + + def test_call_register_hooks_missing_callback_is_quiet_skip( + self, + install_tool_package, + capsys, + ) -> None: + """A facade without ``register_hooks`` is a normal condition — no noise.""" + install_tool_package("goga_tool_silent") + + package = ToolPackage(module_name="goga_tool_silent") + + assert call_register_hooks(package, object()) is False + assert capsys.readouterr().err == "" + + def test_call_register_hooks_non_callable_callback_is_quiet_skip( + self, + install_tool_package, + capsys, + ) -> None: + """A ``register_hooks`` attribute that is not callable — a silent False.""" + install_tool_package("goga_tool_broken_promise", register_hooks=None) + package = ToolPackage(module_name="goga_tool_broken_promise") + + assert call_register_hooks(package, object()) is False + assert capsys.readouterr().err == "" + + def test_call_register_hooks_broken_import_raises_clean_error_naming_package(self) -> None: + """A foreign, transitive import failure — the single fatal case.""" + package = ToolPackage(module_name="goga_tool_demo") + failure = ModuleNotFoundError("No module named 'dep'", name="dep") + + with ( + mock.patch.object(packages, "import_module", side_effect=failure), + pytest.raises(ImportError, match=r"package goga_tool_demo failed to import") as info, + ): + call_register_hooks(package, object()) + + assert info.value.__cause__ is failure + + def test_call_register_hooks_syntax_error_raises_clean_error_naming_package(self) -> None: + """A package that fails to parse — wrapped the same clean way.""" + package = ToolPackage(module_name="goga_tool_demo") + failure = SyntaxError("invalid syntax (goga_tool_demo/__init__.py, line 1)") + + with ( + mock.patch.object(packages, "import_module", side_effect=failure), + pytest.raises(ImportError, match=r"package goga_tool_demo failed to import"), + ): + call_register_hooks(package, object()) + + def test_call_register_hooks_callback_exception_propagates_unchanged(self) -> None: + """A callback crash is not wrapped and not swallowed — the caller decides.""" + failure = ValueError("boom") + + def callback(hooks: object) -> None: + raise failure + + module = ModuleType("goga_tool_demo") + module.register_hooks = callback + + with ( + mock.patch.object(packages, "import_module", return_value=module), + pytest.raises(ValueError, match="boom") as info, + ): + call_register_hooks(ToolPackage(module_name="goga_tool_demo"), object()) + + assert info.value is failure diff --git a/tests/hooks/tools/test_registration.py b/tests/hooks/tools/test_registration.py new file mode 100644 index 00000000..fe3dc075 --- /dev/null +++ b/tests/hooks/tools/test_registration.py @@ -0,0 +1,326 @@ +"""Contract and logic tests for the entities declared in +``goga/hooks/tools/CODEMANIFEST`` with ``location: registration.py``: + +- ``HookRegistrar(tool)`` — the controlled registration surface of one tool +- ``Subscription(tool, domain, action, name, hook)`` — one accepted envelope +- ``RejectedRegistration(tool, domain, action, name, reason)`` — one refused + envelope with its reason + +The catalog read is the only seam: a test that needs a wider catalog +monkeypatches ``declared_actions`` in the namespace of the module under test. +The registrar itself runs for real — a hook is never called, so a plain +function stands in for one. +""" + +from __future__ import annotations + +import dataclasses +import importlib +import inspect +import typing + +import pytest +from goga.hooks.catalog import Action +from goga.hooks.tools import HookRegistrar, RejectedRegistration, Subscription, registration + +from tests.conftest import is_kw_only_dataclass + +_CELL_ALL = [ + "HookRegistrar", + "RejectedRegistration", + "Subscription", + "ToolPackage", + "call_register_hooks", + "enumerate_tool_packages", +] + + +def _hook() -> None: + """A stand-in registered callable — the registrar never calls it.""" + + +# --- Contract tests --- + + +class TestRegistrationContract: + def test_entities_are_importable_from_the_package_facade(self) -> None: + """All three entities live on the cell package and its ``__all__`` is exact.""" + import goga.hooks.tools as cell + + assert cell.HookRegistrar is HookRegistrar + assert cell.Subscription is Subscription + assert cell.RejectedRegistration is RejectedRegistration + assert cell.__all__ == _CELL_ALL + + for name in _CELL_ALL: + assert getattr(cell, name, None) is not None + + def test_module_defines_no_public_entity_beyond_the_three(self) -> None: + """No extra API on ``registration.py``.""" + module = importlib.import_module("goga.hooks.tools.registration") + defined = { + name + for name, value in vars(module).items() + if not name.startswith("_") and getattr(value, "__module__", None) == module.__name__ + } + + assert defined == {"HookRegistrar", "RejectedRegistration", "Subscription"} + + def test_cell_does_not_import_from_registry_or_dispatch(self) -> None: + """The cell stays below registry and dispatch in the dependency order.""" + modules = [ + importlib.import_module("goga.hooks.tools"), + importlib.import_module("goga.hooks.tools.packages"), + importlib.import_module("goga.hooks.tools.registration"), + ] + + for module in modules: + origins = {str(getattr(value, "__module__", "")) for value in vars(module).values()} + + assert not any(origin.startswith("goga.hooks.registry") for origin in origins) + assert not any(origin.startswith("goga.hooks.dispatch") for origin in origins) + + def test_hook_registrar_is_a_kw_only_accumulating_dataclass(self) -> None: + """``HookRegistrar(tool=...)`` — keyword-only, mutable, private state.""" + registrar = HookRegistrar(tool="t") + + assert registrar.tool == "t" + assert dataclasses.is_dataclass(HookRegistrar) + assert is_kw_only_dataclass(HookRegistrar) + assert not HookRegistrar.__dataclass_params__.frozen + + assert [(f.name, f.init, f.repr) for f in dataclasses.fields(HookRegistrar)] == [ + ("tool", True, True), + ("_subscriptions", False, False), + ("_rejections", False, False), + ] + + with pytest.raises(TypeError): + HookRegistrar("t") # type: ignore[misc] + + def test_subscribe_signature(self) -> None: + """``subscribe(self, domain, action, name, hook)`` — nothing else.""" + signature = inspect.signature(HookRegistrar.subscribe) + return_hint = typing.get_type_hints(HookRegistrar.subscribe)["return"] + + assert list(signature.parameters) == ["self", "domain", "action", "name", "hook"] + assert return_hint is type(None) + + def test_subscription_is_a_kw_only_frozen_record(self) -> None: + """``Subscription`` carries exactly the five declared fields, frozen.""" + subscription = Subscription( + tool="t", + domain="statuses", + action="register_statuses", + name="published", + hook=_hook, + ) + + assert subscription.tool == "t" + assert subscription.domain == "statuses" + assert subscription.action == "register_statuses" + assert subscription.name == "published" + assert subscription.hook is _hook + + assert dataclasses.is_dataclass(Subscription) + assert Subscription.__dataclass_params__.frozen + assert is_kw_only_dataclass(Subscription) + assert [f.name for f in dataclasses.fields(Subscription)] == [ + "tool", + "domain", + "action", + "name", + "hook", + ] + + with pytest.raises(TypeError): + Subscription("t", "statuses", "register_statuses", "published", _hook) # type: ignore[misc] + + with pytest.raises(dataclasses.FrozenInstanceError): + subscription.name = "other" # type: ignore[misc] + + def test_rejected_registration_is_a_kw_only_frozen_record(self) -> None: + """``RejectedRegistration`` carries exactly the five declared fields, frozen.""" + rejection = RejectedRegistration( + tool="t", + domain="statuses", + action="register_statuses", + name="dup", + reason="repeated name on the same address", + ) + + assert rejection.tool == "t" + assert rejection.domain == "statuses" + assert rejection.action == "register_statuses" + assert rejection.name == "dup" + assert rejection.reason == "repeated name on the same address" + + assert dataclasses.is_dataclass(RejectedRegistration) + assert RejectedRegistration.__dataclass_params__.frozen + assert is_kw_only_dataclass(RejectedRegistration) + assert [f.name for f in dataclasses.fields(RejectedRegistration)] == [ + "tool", + "domain", + "action", + "name", + "reason", + ] + + with pytest.raises(TypeError): + RejectedRegistration("t", "statuses", "register_statuses", "dup", "why") # type: ignore[misc] + + with pytest.raises(dataclasses.FrozenInstanceError): + rejection.reason = "other" # type: ignore[misc] + + def test_registrar_reads_start_empty(self) -> None: + """``subscriptions`` and ``rejections`` are readable and start empty.""" + registrar = HookRegistrar(tool="t") + + assert isinstance(registrar.subscriptions, list) + assert isinstance(registrar.rejections, list) + assert registrar.subscriptions == [] + assert registrar.rejections == [] + + +# --- Logic tests: the accepted envelope --- + + +class TestSubscribeAccepts: + def test_subscribe_accepts_envelope_and_qualifies_with_registrar_tool(self) -> None: + """One accepted envelope becomes one subscription qualified by the tool.""" + registrar = HookRegistrar(tool="my-tool") + + registrar.subscribe("statuses", "register_statuses", "published", _hook) + + assert registrar.subscriptions == [ + Subscription( + tool="my-tool", + domain="statuses", + action="register_statuses", + name="published", + hook=_hook, + ) + ] + assert registrar.rejections == [] + + def test_subscribe_same_name_different_address_is_allowed(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Uniqueness is per tool per address — the same name elsewhere applies.""" + monkeypatch.setattr( + registration, + "declared_actions", + lambda: [ + Action(domain="statuses", name="register_statuses", error_class="soft"), + Action(domain="statuses", name="register_other", error_class="soft"), + ], + ) + registrar = HookRegistrar(tool="t") + + registrar.subscribe("statuses", "register_statuses", "x", _hook) + registrar.subscribe("statuses", "register_other", "x", _hook) + + assert [(s.domain, s.action, s.name) for s in registrar.subscriptions] == [ + ("statuses", "register_statuses", "x"), + ("statuses", "register_other", "x"), + ] + assert registrar.rejections == [] + + def test_subscribe_keeps_registration_order(self) -> None: + """The accepted subscriptions read back in the order they arrived.""" + registrar = HookRegistrar(tool="t") + + for name in ("third", "first", "second"): + registrar.subscribe("statuses", "register_statuses", name, _hook) + + assert [s.name for s in registrar.subscriptions] == ["third", "first", "second"] + + def test_subscribe_does_not_call_or_inspect_the_hook(self) -> None: + """The callable is recorded verbatim — never called, never inspected.""" + calls: list[object] = [] + + def counted() -> None: + calls.append(counted) + + registrar = HookRegistrar(tool="t") + + registrar.subscribe("statuses", "register_statuses", "counted", counted) + registrar.subscribe("statuses", "register_statuses", "builtin", dict) # no signature + + assert calls == [] + assert [s.hook for s in registrar.subscriptions] == [counted, dict] + assert registrar.rejections == [] + + def test_subscription_and_rejection_reads_are_copies(self) -> None: + """Mutating a returned list never reaches the registrar state.""" + registrar = HookRegistrar(tool="t") + registrar.subscribe("statuses", "register_statuses", "published", _hook) + + registrar.subscriptions.clear() + registrar.rejections.clear() + + assert [s.name for s in registrar.subscriptions] == ["published"] + assert registrar.rejections == [] + + +# --- Logic tests: the refused envelope --- + + +class TestSubscribeRejects: + def test_subscribe_unknown_address_is_rejected_with_warning(self, caplog: pytest.LogCaptureFixture) -> None: + """An address outside the catalog is refused — data, not an exception.""" + registrar = HookRegistrar(tool="t") + + registrar.subscribe("nope", "no_action", "n", _hook) + + assert registrar.subscriptions == [] + assert [r.reason for r in registrar.rejections] == ["unknown action nope.no_action"] + assert (registrar.rejections[0].tool, registrar.rejections[0].name) == ("t", "n") + + assert "rejected hook of tool t on nope.no_action: unknown action nope.no_action" in caplog.text + + def test_subscribe_invalid_envelope_is_rejected_and_partial_registrations_survive( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Every violation is refused on its own; the accepted one survives.""" + registrar = HookRegistrar(tool="t") + + registrar.subscribe("statuses", "register_statuses", "ok", _hook) + registrar.subscribe("statuses", "register_statuses", "", _hook) + registrar.subscribe("statuses", "register_statuses", "not-callable", "not-callable") + registrar.subscribe("statuses", "register_statuses", "ok", _hook) + + assert [s.name for s in registrar.subscriptions] == ["ok"] + assert [r.reason for r in registrar.rejections] == [ + "name must be a non-empty string", + "hook must be callable", + "repeated name on the same address", + ] + + assert caplog.text.count("rejected hook of tool t on statuses.register_statuses:") == 3 + + def test_subscribe_non_string_name_is_rejected_with_an_empty_name( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A name that is not a string lands in the refusal as an empty string.""" + registrar = HookRegistrar(tool="t") + + registrar.subscribe("statuses", "register_statuses", None, _hook) # type: ignore[arg-type] + + assert registrar.subscriptions == [] + assert registrar.rejections[0].name == "" + assert registrar.rejections[0].reason == "name must be a non-empty string" + assert "rejected hook of tool t on statuses.register_statuses:" in caplog.text + + def test_subscribe_repeats_are_refused_per_registrar(self) -> None: + """Two tools hold separate registrars — the same name applies for both.""" + first = HookRegistrar(tool="a") + second = HookRegistrar(tool="b") + + for registrar in (first, second): + registrar.subscribe("statuses", "register_statuses", "published", _hook) + + assert [s.tool for s in first.subscriptions] == ["a"] + assert [s.tool for s in second.subscriptions] == ["b"] + assert first.rejections == [] + assert second.rejections == [] diff --git a/tests/integration/test_base_ref_end_to_end.py b/tests/integration/test_base_ref_end_to_end.py new file mode 100644 index 00000000..81c4a527 --- /dev/null +++ b/tests/integration/test_base_ref_end_to_end.py @@ -0,0 +1,257 @@ +"""End-to-end integration tests for the review-scoped ``base_ref`` option. + +These stitch together the cross-cell path introduced by the +``add-ref-for-review`` change: + + host container + goga/commands/build (click value goga/build/__main__ (argparse value + option --base-ref) option --base-ref) + -> cli_flags -> docker run args -> cli_options["base_ref"] + -> resolve_review_options step 6-7 + .goga/config.yml build.review_executor (CLI > review_executor > omit) + -> load_project_config (loader step 7) + -> ReviewOptions.base_ref/.patience + -> _review_scoped_options -> pass composition (review-carrying + passes only) -> run_ralphex options keys base_ref / + review_patience -> ralphex flags --base-ref / --review-patience + +Three seams only hold end-to-end and are verified here: the value survives the +host->container handoff as the exact docker-run token pair and is parsed back +by the real in-container argparse wiring; an unset option forwards no token and +still lands as a present-but-None ``cli_options`` key (the tri-state that lets +the resolver defer to the config); and a config-declared review base reaches +the ralphex argv of the review pass only — never the tasks pass. + +Mocks live only on the external boundaries per the project conventions: the +DockerRunner (docker binary), ``run_ralphex`` (ralphex binary), the vendored +defaults constants (maintainers' artifact), ``resolve_wrapper_path`` inside the +validator (existence check), and the host's docker/git subprocess helpers. +""" + +from __future__ import annotations + +import sys +from contextlib import contextmanager +from pathlib import Path +from unittest import mock + +import yaml +from click.testing import CliRunner +from goga.build.__main__ import main as container_main +from goga.build.build import build +from goga.commands import build as build_cmd +from goga.config import load_project_config +from goga.ralphex.run_ralphex import _build_command + +# goga.commands.build.build is shadowed by the function re-exported on the +# package __init__, so the real module is resolved via sys.modules and patched +# by attribute (per [[feedback_mock_patch_module_shadowing]]). +_build_cmd_mod = sys.modules["goga.commands.build.build"] + +_ROLES = ("quality", "implementation", "testing", "simplification", "documentation") + +# Synthetic stand-ins for the vendored ralphex v1.6.1 review prompts. The real +# assets are a maintainers' artifact; tests never depend on it. These tests do +# not exercise role filtering (see test_skip_review_end_to_end.py), so only the +# counter fragments the rewrite touches are carried. +_REVIEW_FIRST_TEMPLATE = ( + "# first review prompt\n" + "launches 5 parallel reviewer agents\n" + "Launch ALL 5 Review Agents\n" + "All 5 agent invocations\n" + "".join(f"{{{{agent:{role}}}}}\n" for role in _ROLES) + "until ALL 5 agents\n" +) + +_REVIEW_SECOND_TEMPLATE = ( + "# second review prompt\n" + "uses 2 agents\n" + "Both agent invocations\n" + "{{agent:quality}}\n" + "{{agent:implementation}}\n" + "until both complete\n" + "until BOTH agents\n" + "emit them both in one response\n" +) + + +def _write_goga_yml(tmp_path: Path, review_executor: dict | None = None) -> None: + """Materialize a .goga/config.yml with the optional build.review_executor section.""" + build_section: dict = {"task_executor": {"agent": "claude"}} + + if review_executor is not None: + build_section["review_executor"] = review_executor + + data = { + "language": "python", + "image": "goga:latest", + "build": build_section, + "pipeline": {"agent": "claude"}, + } + goga_dir = tmp_path / ".goga" + goga_dir.mkdir(parents=True, exist_ok=True) + (goga_dir / "config.yml").write_text(yaml.dump(data)) + + +@contextmanager +def _mock_vendored_sources(tmp_path: Path): + """Point the vendored ralphex defaults at synthetic tmp sources (external boundary).""" + from goga.build import ralphex_runtime + + prompts_dir = tmp_path / "vendored-prompts" + agents_dir = tmp_path / "vendored-agents" + prompts_dir.mkdir(parents=True, exist_ok=True) + agents_dir.mkdir(parents=True, exist_ok=True) + (prompts_dir / "task.txt").write_text("# task prompt\n") + (prompts_dir / "codex.txt").write_text("# codex review prompt\n") + (prompts_dir / "review_first.txt").write_text(_REVIEW_FIRST_TEMPLATE) + (prompts_dir / "review_second.txt").write_text(_REVIEW_SECOND_TEMPLATE) + + for role in _ROLES: + (agents_dir / f"{role}.txt").write_text(f"# {role} agent definition\n") + + with ( + mock.patch.object(ralphex_runtime, "_VENDORED_PROMPTS", prompts_dir), + mock.patch.object(ralphex_runtime, "_VENDORED_AGENTS", agents_dir), + ): + yield + + +class TestBaseRefSurvivesHostToContainer: + """The review diff base reaches cli_options["base_ref"] undistorted. + + The host click value option (``--base-ref``, default None) is forwarded as + the exact token pair into the docker run args; the container argparse value + option parses those same tokens back into one dest. Any lossy conversion on + either side (the host resolving None against the config, or the token pair + being dropped in ``_cli_flags_to_args``) would break the CLI > + ``build.review_executor.*`` > omit precedence that lives in + ``resolve_review_options``. + """ + + def test_base_ref_survives_host_to_container(self, tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _write_goga_yml(tmp_path) + + runner = CliRunner() + + with ( + mock.patch.object(_build_cmd_mod, "_check_docker", return_value=True), + mock.patch.object(_build_cmd_mod, "_write_env_file", return_value=Path("/tmp/env")), + mock.patch.object(_build_cmd_mod, "DockerRunner") as mock_runner, + ): + mock_runner.return_value.run.return_value = 0 + runner.invoke(build_cmd, ["plan.md", "--base-ref", "origin/1.2.x"]) + + # The exact tokens handed to docker run form the in-container argv; with + # no other option set, --base-ref and its value are the whole tail. + container_args = mock_runner.return_value.run.call_args.args[0] + forwarded = container_args[container_args.index("plan.md") + 1 :] + assert forwarded == ["--base-ref", "origin/1.2.x"] + + # The container parses those same tokens with its real argparse wiring; + # only the dispatch target is mocked to capture cli_options. + monkeypatch.setenv("GOGA_DOCKER", "1") + monkeypatch.setattr(sys, "argv", ["goga.build", "plan.md", *forwarded]) + + with ( + mock.patch("goga.build.__main__.build", return_value=0) as mock_build, + mock.patch("goga.build.__main__.load_project_config"), + ): + container_main() + + assert mock_build.call_args[0][2]["base_ref"] == "origin/1.2.x" + + def test_base_ref_unset_forwards_no_token(self, tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _write_goga_yml(tmp_path) + + runner = CliRunner() + + with ( + mock.patch.object(_build_cmd_mod, "_check_docker", return_value=True), + mock.patch.object(_build_cmd_mod, "_write_env_file", return_value=Path("/tmp/env")), + mock.patch.object(_build_cmd_mod, "DockerRunner") as mock_runner, + ): + mock_runner.return_value.run.return_value = 0 + runner.invoke(build_cmd, ["plan.md"]) + + # An unset value option emits no token at all — the decision is left to + # the container config, not baked in as an empty value. + container_args = mock_runner.return_value.run.call_args.args[0] + forwarded = container_args[container_args.index("plan.md") + 1 :] + assert forwarded == [] + assert "--base-ref" not in container_args + + # The tri-state survives: the key is present in cli_options with value + # None, so the resolver falls through to build.review_executor.base_ref. + monkeypatch.setenv("GOGA_DOCKER", "1") + monkeypatch.setattr(sys, "argv", ["goga.build", "plan.md", *forwarded]) + + with ( + mock.patch("goga.build.__main__.build", return_value=0) as mock_build, + mock.patch("goga.build.__main__.load_project_config"), + ): + container_main() + + cli_options = mock_build.call_args[0][2] + assert "base_ref" in cli_options + assert cli_options["base_ref"] is None + + +class TestConfigBaseReachesRalphexFlag: + """A config-declared review base reaches the ralphex argv of the review pass only. + + Loader (step 7) -> resolve_review_options (steps 6-7) -> pass composition + (review-scoped fragment joined onto the review-carrying pass only) -> + ``_build_command`` mapping the composed option keys to the ralphex flags. + """ + + def test_config_base_reach_ralphex_flag_on_review_pass(self, tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + _write_goga_yml( + tmp_path, + review_executor={"agent": "codex", "base_ref": "origin/1.2.x", "patience": 3}, + ) + Path("plan.md").write_text("# plan\n") + review_wrapper = tmp_path / "codex-as-claude.sh" + review_wrapper.write_text("#!/bin/sh\n") + + config = load_project_config() + + with ( + _mock_vendored_sources(tmp_path), + mock.patch("goga.build.review_config.resolve_wrapper_path", return_value=str(review_wrapper)), + mock.patch("goga.build.build_pass.run_ralphex", return_value=0) as mock_run, + ): + result = build("plan.md", config, {"skip_manifest_check": True}) + + assert result == 0 + assert mock_run.call_count == 2 + + # The review-carrying pass alone carries the review-scoped flags: the + # options keys base_ref / review_patience map onto the ralphex value + # flags --base-ref / --review-patience. The full argv is pinned (not + # just flag membership) so a flag/value transposition or a stray token + # fails the test. + second_cmd = _build_command("plan.md", mock_run.call_args_list[1].args[1]) + assert second_cmd == [ + "ralphex", + "plan.md", + "--config-dir", + ".ralphex/", + "--review", + "--review-patience", + "3", + "--base-ref", + "origin/1.2.x", + ] + + # The tasks pass carries the universal options only — a diff base on the + # task pass would scope the wrong phase of the run. + first_cmd = _build_command("plan.md", mock_run.call_args_list[0].args[1]) + assert first_cmd == [ + "ralphex", + "plan.md", + "--config-dir", + ".ralphex/", + "--tasks-only", + ] diff --git a/tests/integration/test_compile_flow_memory_integration.py b/tests/integration/test_compile_flow_memory_integration.py new file mode 100644 index 00000000..95f68c50 --- /dev/null +++ b/tests/integration/test_compile_flow_memory_integration.py @@ -0,0 +1,326 @@ +"""Cross-cell integration tests for the workflow-memory surface. + +The cell-local memory tests (``test_parse_workflow_memory.py``, +``test_serialize_flow_memory_slot.py``, ``test_compile_flow_memory.py``, +``test_apply_skip_stages.py``) exercise the memory surface of each cell in +isolation. This module layers ON TOP of them the cross-cell scenarios the +design flags as needing separate verification once every coding task is done — +the full chain workflow parse → skip merge → compile → serialize → text: + +- byte-identity — a memory-free workflow compiles to the byte-exact current + output: a golden literal freezes the full flow-file text, so any memory leak + into the output (a block, a stage key, a key-order shift) changes the string + and fails the test; +- CLI skip channel — ``apply_skip_stages`` feeding ``compile_flow`` is + equivalent to the workflow ``skip`` channel (design scenario 3): skipping the + only participating stage disables the block, while skipping a bystander + leaves everyone else's participation intact; +- ``PipelineDocument`` mirror — the memory block and the stage memory keys are + output-side only; the mirror stays the faithful mirror of the source + pipeline-file; +- method-selector absence — the goga-side ``method`` selector never reaches + the output under either method. + +No new production code is exercised here that the cell-local tests do not +already cover — this module pins that the three cells COMPOSE. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from goga.pipeline import apply_skip_stages +from goga.pipeline.compiler import FlowDocument, PipelineDocument, compile_flow +from goga.pipeline.workflow import WorkflowDocument, parse_workflow + +# Base STAGES-format pipeline-file for the integration tests — three stages, +# verbatim from the design. +_BASE_STAGES = ( + "name: demo\n" + "description: Demo pipeline\n" + "---\n" + "brainstorm:\n" + " title: Brainstorm\n" + " prompt: Think\n" + "build:\n" + " title: Build\n" + " prompt: Make\n" + "review:\n" + " title: Review\n" + " prompt: Check\n" +) + +# The golden byte-exact output of the memory-free compile: the workflow carries +# only a top-level prompt, so the flow-file carries NO memory block and NO +# reflect / memory_use stage key — exactly the pre-memory output. Frozen from a +# single run of the memory-free compile (the main backward-compatibility +# invariant: a workflow without memory participation compiles byte-identically). +_GOLDEN_MEMORY_FREE = ( + "prompt: |-\n" + " P\n" + "name: demo\n" + "description: Demo pipeline\n" + "stages:\n" + "- id: brainstorm\n" + " name: Brainstorm\n" + " prompt: Think\n" + " agents: [auto]\n" + "- id: build\n" + " name: Build\n" + " prompt: Make\n" + " agents: [auto]\n" + "- id: review\n" + " name: Review\n" + " prompt: Check\n" + " agents: [auto]\n" +) + +# A reflect-method workflow that participates (emission case 6 — a block with +# a reflect instruction): the block carries the fixed mode: r and the global +# memory_use: false alongside path / max_rules / commit. +_REFLECT_WORKFLOW = "memory:\n max_rules: 40\nstages:\n brainstorm:\n reflect:\n file: shared.md\n" + +# An alignment-method workflow that participates (emission case 4): the block +# carries the composed path, the materialized mode (rw by default), and the +# global memory_use: false. +_ALIGNMENT_WORKFLOW = ( + "memory:\n method: alignment\n path: goga-development\nstages:\n brainstorm:\n memory: true\n" +) + + +def _write(tmp_path: Path, name: str, text: str) -> Path: + """Write ``text`` to ``tmp_path / name`` and return the path. + + Args: + tmp_path: The pytest temporary directory of the test. + name: The file name to write under. + text: The file content. + + Returns: + The path of the written file. + """ + path = tmp_path / name + path.write_text(text) + return path + + +def _compile( + tmp_path: Path, + pipeline_text: str, + workflow_text: str | None = None, +) -> tuple[PipelineDocument, FlowDocument, str]: + """Write the pipeline (and optional workflow), compile, return documents + text. + + Drives the real parser→compiler handoff: the workflow-file is parsed by + ``parse_workflow`` and the resulting document handed to ``compile_flow``, + so the assertions cover the composed surface, not a hand-built document. + + Args: + tmp_path: The pytest temporary directory of the test. + pipeline_text: The pipeline-file content to compile. + workflow_text: The optional workflow-file content; ``None`` compiles + without a workflow. + + Returns: + The ``(PipelineDocument, FlowDocument, text)`` triple — the documents + returned by ``compile_flow`` plus the compiled flow-file text. + """ + workflow: WorkflowDocument | None = None + + if workflow_text is not None: + workflow = parse_workflow(_write(tmp_path, "workflow.yml", workflow_text)) + + pipeline_path = _write(tmp_path, "pipeline.yml", pipeline_text) + flow_path = tmp_path / "flow.yml" + + pipeline_doc, flow_doc = compile_flow(pipeline_path, flow_path, workflow=workflow) + + return pipeline_doc, flow_doc, flow_path.read_text() + + +class TestMemoryFreeByteIdentity: + """The byte-identity gate — a memory-free workflow compiles byte-identically.""" + + def test_compile_flow_memory_free_workflow_compiles_byte_identically(self, tmp_path: Path) -> None: + """A workflow with only a prompt compiles to the exact pre-memory output. + + The workflow-file carries nothing but ``prompt: "P"`` — no memory block, + no participation instruction — so ``compile_flow`` must produce the same + bytes as before the feature existed: the golden literal pins the full + flow-file text (prompt block-literal first, then name, description, and + the three stages with their ``agents: [auto]`` lines, and nothing else). + Any memory leak into the output — a ``memory:`` block, a stage + ``reflect`` / ``memory_use`` key, or a key-order shift — changes the + string and fails the test. + """ + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, 'prompt: "P"\n') + + assert text == _GOLDEN_MEMORY_FREE + assert flow_doc.memory is None + assert not any("reflect" in stage.fields or "memory_use" in stage.fields for stage in flow_doc.stages) + + +class TestSkipChannelParity: + """The CLI skip channel — ``apply_skip_stages`` feeding ``compile_flow``. + + The workflow ``skip`` channel is covered cell-locally + (``test_compile_flow_memory.py``); these drive the CLI channel — the + rebuilt ``WorkflowDocument`` from ``apply_skip_stages`` (which must carry + the memory configuration verbatim) handed to ``compile_flow``. + """ + + @pytest.mark.parametrize( + ("skip_stages", "expects_block"), + [ + pytest.param(["brainstorm"], False, id="skip-participating-disables-block"), + pytest.param(["review"], True, id="skip-bystander-keeps-block"), + ], + ) + def test_compile_flow_skip_via_cli_channel_uses_same_path( + self, + tmp_path: Path, + skip_stages: list[str], + expects_block: bool, + ) -> None: + """The CLI skip channel is equivalent to the workflow skip channel. + + The workflow carries a single participating stage (``brainstorm`` with + a reflect instruction). Skipping it through the CLI channel + (``apply_skip_stages`` → ``compile_flow``) removes the stage before the + participation count, so the block is not emitted — design scenario 3. + Skipping a non-participating bystander (``review``) leaves everyone + else's participation intact: the block stays, ``brainstorm`` keeps its + reflect key, and ``review`` disappears from the output. + """ + workflow_text = "stages:\n brainstorm:\n reflect:\n file: a.md\n" + workflow = parse_workflow(_write(tmp_path, "workflow.yml", workflow_text)) + merged = apply_skip_stages(workflow, skip_stages) + + pipeline_path = _write(tmp_path, "pipeline.yml", _BASE_STAGES) + flow_path = tmp_path / "flow.yml" + + _pipeline_doc, flow_doc = compile_flow(pipeline_path, flow_path, workflow=merged) + text = flow_path.read_text() + + if not expects_block: + assert flow_doc.memory is None + assert "memory:" not in text + assert all("reflect" not in stage.fields for stage in flow_doc.stages) + assert "brainstorm" not in {stage.id for stage in flow_doc.stages} + else: + assert flow_doc.memory is not None + brainstorm = next(stage for stage in flow_doc.stages if stage.id == "brainstorm") + + assert brainstorm.fields["reflect"] == {"file": "a.md", "mode": "rw"} + assert "review" not in {stage.id for stage in flow_doc.stages} + + +class TestComposedBlockValues: + """The parser→compiler handoff emits the required block values per method.""" + + @pytest.mark.parametrize( + ("workflow_text", "mode"), + [ + pytest.param(_REFLECT_WORKFLOW, "r", id="reflect-fixed-r"), + pytest.param(_ALIGNMENT_WORKFLOW, "rw", id="alignment-materialized-rw"), + ], + ) + def test_compile_flow_block_mode_and_global_opt_out( + self, + tmp_path: Path, + workflow_text: str, + mode: str, + ) -> None: + """Both methods emit their ``mode`` and the global ``memory_use: false``. + + The real ``parse_workflow`` materializes the configuration and hands it + to ``compile_flow`` — the composed surface (not a hand-built document) + must carry the fixed ``mode: r`` under reflect, the materialized + ``mode: rw`` under alignment, and the global participation opt-out + ``memory_use: false`` under both. + """ + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is not None + assert flow_doc.memory.mode == mode + assert flow_doc.memory.memory_use is False + + block_text = text.split("stages:")[0].split("memory:")[1] + + assert f"mode: {mode}" in block_text + assert "memory_use: false" in block_text + + +class TestPipelineDocumentMirror: + """Output-side only — the ``PipelineDocument`` mirror ignores memory.""" + + @pytest.mark.parametrize( + "workflow_text", + [ + pytest.param(_REFLECT_WORKFLOW, id="reflect"), + pytest.param(_ALIGNMENT_WORKFLOW, id="alignment"), + ], + ) + def test_compile_flow_pipeline_document_unaffected_by_memory( + self, + tmp_path: Path, + workflow_text: str, + ) -> None: + """The memory block and the stage keys never leak into ``PipelineDocument``. + + A participating memory workflow (both methods) compiles, and the + returned ``PipelineDocument`` stays the faithful mirror of the source + pipeline-file: no step body carries the output-side ``reflect`` / + ``memory_use`` keys, and the body equals the body of the same pipeline + compiled WITHOUT a workflow. + """ + pipeline_doc, flow_doc, _text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is not None + + for step in pipeline_doc.body.steps: + assert "reflect" not in step.body + assert "memory_use" not in step.body + + bare_path = _write(tmp_path, "bare-pipeline.yml", _BASE_STAGES) + bare_doc, _bare_flow_doc = compile_flow(bare_path, tmp_path / "bare-flow.yml") + + assert pipeline_doc.body == bare_doc.body + + +class TestMethodSelectorAbsence: + """The goga-side ``method`` selector never reaches the output.""" + + @pytest.mark.parametrize( + "workflow_text", + [ + pytest.param(_REFLECT_WORKFLOW, id="reflect"), + pytest.param(_ALIGNMENT_WORKFLOW, id="alignment"), + ], + ) + def test_compile_flow_method_selector_never_in_output( + self, + tmp_path: Path, + workflow_text: str, + ) -> None: + """Neither method name nor the selector vocabulary appears in the text. + + Both participating scenarios (reflect and alignment) emit the memory + block, yet the emitted surface carries only the afm vocabulary: no + ``method:`` key, no ``alignment:`` selector value, and no ``reflect`` + or ``memory_use`` at the TOP level (``reflect`` legitimately appears + nested inside stage bodies — the parsed top-level key set pins that). + """ + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is not None + assert "method:" not in text + assert "alignment:" not in text + + top_level = yaml.safe_load(text) + + assert "reflect" not in top_level + assert "memory_use" not in top_level + assert set(top_level) <= {"name", "description", "memory", "stages"} diff --git a/tests/integration/test_connect_tool_after_install.py b/tests/integration/test_connect_tool_after_install.py index 1495e4d7..c757a1fa 100644 --- a/tests/integration/test_connect_tool_after_install.py +++ b/tests/integration/test_connect_tool_after_install.py @@ -49,9 +49,16 @@ def test_resync_registered_agents_in_connect_all(self) -> None: def test_install_facade_surface_unchanged(self) -> None: # The install cell facade carries both lifecycle commands — install and - # uninstall — since the uninstall contract was materialized. + # uninstall — since the uninstall contract was materialized, plus the + # three ``hook.py`` routines since release 1.3.0. facade = importlib.import_module("goga.commands.install") - assert facade.__all__ == ["install", "uninstall"] + assert facade.__all__ == [ + "call_install_hook", + "install", + "resolve_initiating_user", + "run_install_hooks", + "uninstall", + ] def test_upgrade_facade_surface_unchanged(self) -> None: facade = importlib.import_module("goga.commands.upgrade") diff --git a/tests/integration/test_history_command.py b/tests/integration/test_history_command.py new file mode 100644 index 00000000..6ddac2bf --- /dev/null +++ b/tests/integration/test_history_command.py @@ -0,0 +1,522 @@ +"""Integration tests for the ``goga history`` command group. + +Cross-entity scenarios — group → subcommand → domain → render → +stdout/filesystem: the command layer resolves the inputs, the +``goga.history`` domain computes, the ``render`` module prints. The negative +paths live in ``tests/commands/history/test_history.py``; this file drives the happy paths and the +empty-result edges through the real command objects. + +Setup follows the cell conventions: ``tmp_path`` + ``monkeypatch.chdir`` for +the filesystem, ``CliRunner`` for the CLI surface (captured output is not a +TTY, so ANSI is stripped), the pinned clock ``naming.datetime`` wherever the +year must be deterministic, and the branch reader mocked at its import site +in the command module. +""" + +from __future__ import annotations + +import inspect +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from types import ModuleType +from typing import Any +from unittest import mock + +import pytest +from click.testing import CliRunner +from goga.commands.history import history +from goga.history import naming + +# goga.commands.history.history is shadowed in the package __init__ by the +# history click group, so attribute access through the package gives the +# group. Resolve the real module via sys.modules (precedent: test_history.py). +_history_module = sys.modules["goga.commands.history.history"] + + +class _FixedClock: + """Stand-in for ``datetime`` answering a fixed naive date.""" + + @staticmethod + def now() -> datetime: + return datetime(2031, 6, 15) # noqa: DTZ001 — a fixed naive date is the point of the clock + + +def _fake_tool_packages(monkeypatch: pytest.MonkeyPatch) -> None: + """Isolate the scale assembly to one fake tool package. + + ``goga_tool_mkdocs`` subscribes ``published`` on the status action and + registers it anchored after ``planned``, so a topic carrying both + ``plan.md`` and ``mkdocs/published.md`` has the single maximal status + ``mkdocs.published``. The enumeration patch keeps the real tool packages + of the environment out of the assembled scale. + """ + + def register_hooks(hooks: Any) -> None: + hooks.subscribe( + "statuses", + "register_statuses", + "published", + lambda context: context.register(name="published", filepath="mkdocs/published.md", after="planned"), + ) + + module = ModuleType("goga_tool_mkdocs") + module.register_hooks = register_hooks + monkeypatch.setitem(sys.modules, "goga_tool_mkdocs", module) + monkeypatch.setattr( + "goga.hooks.tools.packages.packages_distributions", + lambda: {"goga_tool_mkdocs": ["goga-tool-mkdocs"]}, + ) + + +# --- Cross-entity interactions --- + + +class TestHistoryList: + def test_history_list_renders_tree(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """list prints the inventory: years with topics, no statuses, no non-years.""" + root = tmp_path / ".goga" / "history" + + for relative in ("2025/b-topic", "2025/a-topic", "2026/history-commands", "backups", "20a6"): + (root / relative).mkdir(parents=True) + (root / "notes.md").write_text("not a year\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["list"]) + + assert result.exit_code == 0 + assert "2025/" in result.output + assert " └── a-topic" in result.output + assert "2026/" in result.output + assert " └── history-commands" in result.output + assert result.output.splitlines() == [ + "2025/", + " └── a-topic", + " └── b-topic", + "2026/", + " └── history-commands", + ] + assert "[planned]" not in result.output + + def test_history_list_scoped_year_renders_one_section( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """-y scopes the inventory to one year — the other years never print.""" + root = tmp_path / ".goga" / "history" + (root / "2025" / "release-1-3-0").mkdir(parents=True) + (root / "2026" / "feat-x").mkdir(parents=True) + (root / "2026" / "history-commands").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["-y", "2025", "list"]) + + assert result.exit_code == 0 + assert result.output.splitlines() == ["2025/", " └── release-1-3-0"] + assert "2026" not in result.output + + +class TestHistoryStatus: + def test_status_signature_defaults(self) -> None: + """``status(scope, topic=None, statuses=())`` — the declared shape.""" + callback = history.commands["status"].callback + signature = inspect.signature(callback) + assert list(signature.parameters) == ["scope", "topic", "statuses"] + # scope is the pass_obj injection — click supplies it, no default. + assert signature.parameters["scope"].default is inspect.Parameter.empty + assert signature.parameters["topic"].default is None + assert signature.parameters["statuses"].default == () + + def test_history_status_scoped_year_collects_that_year( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The scoped year is the year collected — a registered tool status is maximal and filterable.""" + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "release-1-3-0" / "mkdocs").mkdir(parents=True) + (year_dir / "release-1-3-0" / "plan.md").write_text("plan\n", encoding="utf-8") + (year_dir / "release-1-3-0" / "mkdocs" / "published.md").write_text("pub\n", encoding="utf-8") + (year_dir / "alpha").mkdir() + (year_dir / "alpha" / "prd.md").write_text("prd\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + _fake_tool_packages(monkeypatch) + + result = CliRunner().invoke(history, ["-y", "2026", "status", "-s", "mkdocs.published"]) + + assert result.exit_code == 0 + assert result.output.splitlines() == ["release-1-3-0 [mkdocs.published]"] + assert "alpha" not in result.output + + def test_history_status_unknown_filter_name_clean_error(self) -> None: + """An unknown -s name fails before any collection — clean, exit 1.""" + with mock.patch.object(_history_module, "collect_topic_statuses") as collect_mock: + result = CliRunner().invoke(history, ["status", "-s", "bogus"]) + + assert result.exit_code == 1 + assert "unknown status name: 'bogus'" in result.stderr + assert "Traceback" not in result.stderr + collect_mock.assert_not_called() + + def test_history_status_empty_topic_filter_rejected(self) -> None: + """A -t value normalizing to an empty slug is rejected, not match-all.""" + result = CliRunner().invoke(history, ["status", "-t", "???"]) + + assert result.exit_code == 1 + assert "empty topic slug" in result.stderr + assert result.stdout == "" + + def test_history_status_filters_and(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """-t and -s combine by AND; the year is resolved, never printed.""" + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "release-1-3-0" / "completed").mkdir(parents=True) + (year_dir / "release-1-3-0" / "completed" / "plan.md").write_text("done\n", encoding="utf-8") + (year_dir / "history-commands").mkdir() + (year_dir / "history-commands" / "plan.md").write_text("plan\n", encoding="utf-8") + (year_dir / "other").mkdir() + (year_dir / "other" / "prd.md").write_text("prd\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["-y", "2026", "status", "-t", "Release/1.3.0", "-s", "done"]) + + assert result.exit_code == 0 + assert result.output.strip() == "release-1-3-0 [done]" + assert "history-commands" not in result.output + assert "other" not in result.output + assert "2026" not in result.output + + def test_history_status_defaults_to_current_year_unfiltered( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Plain status: the current year, no filters — every topic alphabetically.""" + history_root = tmp_path / ".goga" / "history" + (history_root / "2025" / "old-topic").mkdir(parents=True) + year_dir = history_root / "2031" + + for topic in ("alpha", "mid", "zeta"): + (year_dir / topic).mkdir(parents=True) + (year_dir / "alpha" / "plan.md").write_text("plan\n", encoding="utf-8") + (year_dir / "mid" / "prd.md").write_text("prd\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + with mock.patch.object(naming, "datetime", _FixedClock): + result = CliRunner().invoke(history, ["status"]) + + assert result.exit_code == 0 + assert result.output.splitlines() == ["alpha [planned]", "mid [defined]", "zeta [empty]"] + assert "old-topic" not in result.output + + def test_history_status_repeatable_status_filter(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """-s repeats: one -s per name keeps several statuses, drops the rest.""" + year_dir = tmp_path / ".goga" / "history" / "2026" + + for topic in ("done-topic", "planned-topic", "defined-topic"): + (year_dir / topic).mkdir(parents=True) + (year_dir / "done-topic" / "completed").mkdir() + (year_dir / "done-topic" / "completed" / "plan.md").write_text("done\n", encoding="utf-8") + (year_dir / "planned-topic" / "plan.md").write_text("plan\n", encoding="utf-8") + (year_dir / "defined-topic" / "prd.md").write_text("prd\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["-y", "2026", "status", "-s", "planned", "-s", "done"]) + + assert result.exit_code == 0 + assert result.output.splitlines() == ["done-topic [done]", "planned-topic [planned]"] + assert "defined-topic" not in result.output + + def test_history_status_filter_todo_selects_todo_topics( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """-s todo selects the topics a todo file puts into the built-in todo status.""" + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "feat-a").mkdir(parents=True) + (year_dir / "feat-a" / "todo.md").write_text("Payment retry\n", encoding="utf-8") + (year_dir / "feat-b").mkdir() + (year_dir / "feat-b" / "prd.md").write_text("prd\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("goga.hooks.tools.packages.packages_distributions", lambda: {}) + + result = CliRunner().invoke(history, ["-y", "2026", "status", "-s", "todo"]) + + assert result.exit_code == 0 + assert result.output.splitlines() == ["feat-a [todo]"] + assert "feat-b" not in result.output + + def test_history_status_filter_todo_skips_defined_topics( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A topic with prd.md is defined, not todo — the maximal status wins through the CLI.""" + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "feat-b").mkdir(parents=True) + (year_dir / "feat-b" / "todo.md").write_text("Todo\n", encoding="utf-8") + (year_dir / "feat-b" / "prd.md").write_text("prd\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("goga.hooks.tools.packages.packages_distributions", lambda: {}) + + result = CliRunner().invoke(history, ["-y", "2026", "status", "-s", "todo"]) + + assert result.exit_code == 0 + assert result.output == "" + assert (year_dir / "feat-b" / "todo.md").exists() + + def test_history_status_filter_new_unknown(self) -> None: + """The retired new name is rejected — unknown status, clean error, no collection.""" + with mock.patch.object(_history_module, "collect_topic_statuses") as collect_mock: + result = CliRunner().invoke(history, ["-y", "2026", "status", "-s", "new"]) + + assert result.exit_code == 1 + assert "unknown status name: 'new'" in result.stderr + assert "Traceback" not in result.stderr + collect_mock.assert_not_called() + + +class TestHistoryPath: + def test_history_path_prints_file_path_only(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """path answers the branch-defaulted artifact path — one line, nothing created.""" + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + with ( + mock.patch.object(naming, "datetime", _FixedClock), + mock.patch.object(_history_module, "resolve_current_branch_name", return_value="history-commands"), + ): + result = runner.invoke(history, ["path", "-f", "plan.md"]) + + expected = str(Path(".goga/history") / "2031" / "history-commands" / "plan.md") + assert result.exit_code == 0 + assert result.output.splitlines() == [expected] + assert result.output.endswith("\n") + assert not (tmp_path / ".goga").exists() + + def test_history_path_without_file_prints_topic_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """path without -f answers the branch-defaulted topic directory.""" + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + with ( + mock.patch.object(naming, "datetime", _FixedClock), + mock.patch.object(_history_module, "resolve_current_branch_name", return_value="Feature/Foo_Bar"), + ): + result = runner.invoke(history, ["path"]) + + expected = str(Path(".goga/history") / "2031" / "feature-foo-bar") + assert result.exit_code == 0 + assert result.output.splitlines() == [expected] + assert result.output.endswith("\n") + assert not (tmp_path / ".goga").exists() + + def test_history_path_scoped_year_composes_that_year(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """path takes an explicit branch-name topic; the scoped year composes the path.""" + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["-y", "2025", "path", "release/1.3.0", "-f", "plan.md"]) + + expected = str(Path(".goga/history") / "2025" / "release-1-3-0" / "plan.md") + assert result.exit_code == 0 + assert result.output.splitlines() == [expected] + assert not (tmp_path / ".goga").exists() + + +class TestHistoryEnsure: + def test_history_ensure_creates_dir_silently(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """ensure normalizes the branch name and is idempotent — stdout stays empty.""" + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + with ( + mock.patch.object(naming, "datetime", _FixedClock), + mock.patch.object(_history_module, "resolve_current_branch_name", return_value="Feature/Foo_Bar"), + ): + first = runner.invoke(history, ["ensure"]) + second = runner.invoke(history, ["ensure"]) + + assert first.exit_code == 0 + assert second.exit_code == 0 + assert first.output == "" + assert second.output == "" + assert (tmp_path / ".goga" / "history" / "2031" / "feature-foo-bar").is_dir() + + def test_history_ensure_explicit_name_creates_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """ensure with an explicit NAME normalizes it — no git involved.""" + monkeypatch.chdir(tmp_path) + + with mock.patch.object(naming, "datetime", _FixedClock): + result = CliRunner().invoke(history, ["ensure", "Feature/Foo_Bar"]) + + assert result.exit_code == 0 + assert result.output == "" + assert (tmp_path / ".goga" / "history" / "2031" / "feature-foo-bar").is_dir() + + def test_history_ensure_scoped_year_creates_and_is_idempotent( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """ensure addresses the scoped year: created there, twice a success, stdout empty.""" + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + first = runner.invoke(history, ["-y", "2025", "ensure", "Feature/Foo_Bar"]) + second = runner.invoke(history, ["-y", "2025", "ensure", "Feature/Foo_Bar"]) + + assert first.exit_code == 0 + assert second.exit_code == 0 + assert first.output == "" + assert second.output == "" + assert (tmp_path / ".goga" / "history" / "2025" / "feature-foo-bar").is_dir() + + +class TestHistoryPrune: + def test_history_prune_command_prints_slugs(self) -> None: + """prune echoes one slug per line and forwards --dry-run to the domain.""" + runner = CliRunner() + + with mock.patch.object(_history_module, "prune_topics", return_value=["done-c", "orphan-b"]) as prune_mock: + result = runner.invoke(history, ["prune", "--dry-run"]) + + assert result.exit_code == 0 + assert result.output == "done-c\norphan-b\n" + prune_mock.assert_called_once_with(None, True) + + def test_history_prune_scoped_year_passes_year(self) -> None: + """prune forwards the scoped year and --dry-run; the slug list prints.""" + runner = CliRunner() + + with mock.patch.object(_history_module, "prune_topics", return_value=["orphan-topic"]) as prune_mock: + result = runner.invoke(history, ["-y", "2025", "prune", "--dry-run"]) + + assert result.exit_code == 0 + assert result.output.splitlines() == ["orphan-topic"] + prune_mock.assert_called_once_with("2025", True) + + def test_history_prune_empty_year_value_takes_current_year( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """-y '' reaches the domain as an empty string — read as the current year, not the full tree.""" + history_root = tmp_path / ".goga" / "history" + (history_root / "2025" / "orphan-old").mkdir(parents=True) + (history_root / "2031" / "orphan-new").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + with ( + mock.patch.object(naming, "datetime", _FixedClock), + mock.patch("goga.history.prune.list_branch_refs", return_value=[]), + ): + result = CliRunner().invoke(history, ["-y", "", "prune", "--dry-run"]) + + assert result.exit_code == 0 + assert result.output == "orphan-new\n" + assert (history_root / "2025" / "orphan-old").is_dir() + + @pytest.mark.parametrize( + ("failure", "message"), + [ + (subprocess.CalledProcessError(1, ["git"], stderr="boom"), "git failed: boom"), + (FileNotFoundError("git"), "git is not available"), + (OSError("disk quota"), "cannot delete topic directory"), + ], + ) + def test_history_prune_git_failure_is_clean_error(self, failure: Exception, message: str) -> None: + """A domain failure surfaces as a clean error — exit 1, stderr, no traceback.""" + runner = CliRunner() + + with mock.patch.object(_history_module, "prune_topics", side_effect=failure): + result = runner.invoke(history, ["prune"]) + + assert result.exit_code == 1 + assert result.stdout == "" + assert message in result.stderr + assert "Traceback" not in result.stderr + + def test_history_prune_empty_slug_dir_is_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A manual empty-slug directory aborts the cleanup before any deletion.""" + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "orphan-a").mkdir(parents=True) + (year_dir / "orphan-a" / "prd.md").write_text("prd\n", encoding="utf-8") + (year_dir / "ББ").mkdir() + (year_dir / "ББ" / "prd.md").write_text("prd\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + # The empty slug sorts first, so remove_topic_dir("") raises the + # domain ValueError before the list is returned — the echo loop never + # runs and nothing is deleted. + with mock.patch("goga.history.prune.list_branch_refs", return_value=[]): + result = CliRunner().invoke(history, ["-y", "2026", "prune"]) + + assert result.exit_code == 1 + assert result.stdout == "" + assert "normalizes to an empty topic slug" in result.stderr + assert "Traceback" not in result.stderr + assert (year_dir / "orphan-a").exists() + + +# --- Edge cases --- + + +class TestHistoryEmptyResults: + def test_history_status_empty_result_prints_nothing_exit_zero( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A year without topics prints nothing and exits 0 — not an error.""" + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["-y", "1999", "status"]) + + assert result.exit_code == 0 + assert result.output == "" + + def test_history_status_filter_matching_nothing_exit_zero( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A filter matching nothing prints nothing and exits 0 — not an error.""" + year_dir = tmp_path / ".goga" / "history" / "2026" + (year_dir / "history-commands").mkdir(parents=True) + (year_dir / "history-commands" / "plan.md").write_text("plan\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["-y", "2026", "status", "-t", "nomatch"]) + + assert result.exit_code == 0 + assert result.output == "" + + def test_history_list_absent_history_empty_output(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An empty workspace has an empty history — list prints nothing, exit 0.""" + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["list"]) + + assert result.exit_code == 0 + assert result.output == "" + + def test_history_empty_year_value_counts_as_absent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """-y '' is an empty CLI value — the domain reads it as no selection: full tree.""" + root = tmp_path / ".goga" / "history" + (root / "2025" / "feat-a").mkdir(parents=True) + (root / "2026" / "feat-b").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["-y", "", "list"]) + + assert result.exit_code == 0 + assert "2025/" in result.output + assert "2026/" in result.output + + def test_history_off_grammar_year_value_is_not_validated( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An off-grammar -y value passes through unvalidated — the domain answers an empty year.""" + (tmp_path / ".goga" / "history" / "2026" / "feat-x").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["-y", "20a6", "status"]) + + assert result.exit_code == 0 + assert result.output == "" + + def test_history_list_scoped_missing_year_prints_nothing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A scoped year absent from the tree is empty — list prints nothing, exit 0.""" + (tmp_path / ".goga" / "history" / "2026" / "feat-x").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["-y", "2099", "list"]) + + assert result.exit_code == 0 + assert result.output == "" diff --git a/tests/integration/test_topic_workflows.py b/tests/integration/test_topic_workflows.py new file mode 100644 index 00000000..37cadb77 --- /dev/null +++ b/tests/integration/test_topic_workflows.py @@ -0,0 +1,1338 @@ +"""End-to-end integration tests for the topic workflows of one year. + +These exercise the cross-cell paths of the usability-for-topic-workaround +feature over its real surfaces — no domain routine and no renderer is +mocked, only the boundaries the environment cannot provide: + + goga topics board --year Y + -> goga.commands.topics.topics.board + -> goga.topics.collect_topic_board + -> [goga.history.assemble_status_scale (real tool packages) + goga.topics.git.list_branch_refs / read_ref_tree_paths + goga.history.resolve_topic_status (working copy)] + -> goga.commands.topics.render_topic_board + + goga history status -s <tool>.<name> + -> goga.commands.history.history.status + -> goga.history.assemble_status_scale + -> goga.history.collect_topic_statuses + -> goga.commands.history.render_topic_statuses + + goga pipeline -t/--topic — the removed -b/--branch regression, and the + mutating chains of ``switch_topic``/``create_topic`` through the real git + cell: checkout, remote-tracking branch creation, and branch-plus-directory + creation. + + publish_topic — the quarantined fast path over the real git cell: the + todo commit is built off a pushed ``origin/main`` while the working + copy, the index, and HEAD stay as they are, the branch is planted and + pushed to a real bare ``origin``, and the failed-push scenario breaks + the push URL to prove the full rollback of the planted branch. + + resolve_delete_targets/delete_topics — the identified-topic deletion + over the real git cell: the tier resolution reads real + ``for-each-ref`` display names, the twin collapse assembles the local + branch and its origin twin, the merged-work and current-branch guards + block the dangerous states, and the removal deletes from a real bare + ``origin`` — the failed-remote scenario breaks the push URL to prove + the local branch is restored at the captured commit. + +Git is real: the git-dependent scenarios run in a throwaway repository +under ``tmp_path`` (``git init`` plus commits, with ``git update-ref`` +manufacturing the remote-tracking twin) and skip when no git binary is +available. The status-scale assembly runs for real against the installed +``goga_tool_*`` packages; the qualified-name filter scenario first checks +that a registered tool status truly exists in ``assemble_status_scale()`` +and falls back to a fake ``goga_tool_*`` module in ``sys.modules`` plus a +patched packages map otherwise — the command surface, the domain, and the +assembly stay the real ones either way. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import threading +from pathlib import Path +from types import ModuleType +from typing import Any +from unittest import mock + +import click +import pytest +from click.testing import CliRunner +from goga.cli import app +from goga.commands.history import history +from goga.commands.topics import topics +from goga.history import assemble_status_scale, current_year +from goga.topics import ( + DeleteTarget, + create_topic, + delete_topics, + publish_topic, + resolve_delete_targets, + switch_topic, +) +from goga.topics import switching as topics_switching + +# The scenarios drive real git — skip them where no git binary exists. +requires_git = pytest.mark.skipif(shutil.which("git") is None, reason="git binary is not available") + +_GIT_IDENTITY = [ + "-c", + "user.email=goga@example.com", + "-c", + "user.name=goga tests", +] + + +def _git(root: Path, *args: str) -> None: + """Run one git command in the throwaway repository. + + Args: + root: The repository root. + *args: The git arguments. + """ + subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, text=True) + + +def _git_out(root: Path, *args: str) -> str: + """Run one git command in the throwaway repository and capture stdout. + + Args: + root: The repository root. + *args: The git arguments. + + Returns: + The stripped stdout of the command. + """ + result = subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, text=True) + return result.stdout.strip() + + +def _worktree_snapshot(root: Path) -> list[str]: + """List every working-copy path of the throwaway repository. + + Args: + root: The repository root. + + Returns: + The sorted repository-relative posix paths of every file and + directory below ``root`` — the ``.git`` directory excluded, the + state the user sees and the quarantine invariant protects. + """ + return sorted( + path.relative_to(root).as_posix() for path in root.rglob("*") if path.relative_to(root).parts[0] != ".git" + ) + + +def _write(root: Path, relative: str) -> None: + """Create one artifact file of the throwaway history tree. + + Args: + root: The repository root. + relative: The file path relative to the root, directories included. + """ + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("integration\n", encoding="utf-8") + + +def _export_editor(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, body: str) -> None: + """Export ``$EDITOR`` as an executable shell script running ``body``. + + The creation procedure opens the editor session on an interactive + terminal; the script stands in for the real editor — a no-op body + leaves the prefilled file untouched, which the session reads as a + cancellation. + + Args: + monkeypatch: The patcher owning the environment. + tmp_path: The throwaway directory the script is written to. + body: The shell body of the script — ``$1`` is the temp file. + """ + script = tmp_path / "editor-mock.sh" + script.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8") + script.chmod(0o755) + monkeypatch.delenv("VISUAL", raising=False) + monkeypatch.setenv("EDITOR", str(script)) + + +def _current_branch(root: Path) -> str: + """Read the checked-out branch of the throwaway repository. + + Args: + root: The repository root. + + Returns: + The current branch name as git reports it. + """ + result = subprocess.run(["git", "branch", "--show-current"], cwd=root, check=True, capture_output=True, text=True) + return result.stdout.strip() + + +def _add_solo_branch(root: Path) -> None: + """Add a branch hosting exactly one topic of its own. + + An orphan branch — its tree carries no other topic of the year, so it + resolves unambiguously by branch name and by slug alike, unlike the + chained ``feat-b`` which also carries the shared ``feat-a`` history. + + Args: + root: The repository root of an already initialized topic repo. + """ + _git(root, "switch", "-q", "--orphan", "solo") + _write(root, ".goga/history/2025/solo/prd.md") + _git(root, "add", ".goga") + _git(root, *_GIT_IDENTITY, "commit", "-qm", "topic solo") + _git(root, "switch", "-q", "feat-a") + + +def _init_topic_repo(root: Path) -> None: + """Build the throwaway repository the board and switch scenarios share. + + A ``feat-a`` branch hosting the ``feat-a`` topic of 2025 with + ``prd.md`` and ``plan.md`` committed, a ``feat-b`` branch created from + it that adds the ``feat-b`` topic with ``prd.md`` (and so also carries + the shared ``feat-a`` history in its ref tree), the checkout back onto + ``feat-a``, and a remote-tracking twin of ``feat-a`` — the input of the + twin collapse. + + Args: + root: The empty directory the repository is built in. + """ + _git(root, "init", "-q", "-b", "feat-a") + _write(root, ".goga/history/2025/feat-a/prd.md") + _write(root, ".goga/history/2025/feat-a/plan.md") + _git(root, "add", ".goga") + _git(root, *_GIT_IDENTITY, "commit", "-qm", "topic feat-a") + _git(root, "update-ref", "refs/remotes/origin/feat-a", "HEAD") + _git(root, "switch", "-q", "-c", "feat-b") + _write(root, ".goga/history/2025/feat-b/prd.md") + _git(root, "add", ".goga") + _git(root, *_GIT_IDENTITY, "commit", "-qm", "topic feat-b") + _git(root, "switch", "-q", "feat-a") + + +def _init_publish_repo(root: Path) -> Path: + """Build the throwaway repository the publish scenarios share. + + A ``main`` branch with one tracked-file commit, a bare ``origin`` + sibling wired in as the ``origin`` remote with ``origin/main`` + materialized through a real push, and the git identity committed to + the repository config — the domain's ``commit-tree`` invocation carries + no ``-c`` identity of its own, so an unset identity would fail there. + + Args: + root: The empty directory the repository is built in. + + Returns: + The path of the bare origin repository. + """ + _git(root, "init", "-q", "-b", "main") + _git(root, "config", "user.email", "goga@example.com") + _git(root, "config", "user.name", "goga tests") + (root / "tracked.txt").write_text("base\n", encoding="utf-8") + _git(root, "add", "tracked.txt") + _git(root, *_GIT_IDENTITY, "commit", "-qm", "base") + origin = root.parent / f"{root.name}-origin.git" + _git(root, "init", "-q", "--bare", str(origin)) + _git(root, "remote", "add", "origin", str(origin)) + _git(root, "push", "-q", "origin", "main") + return origin + + +def _board_rows(output: str, columns: int = 3) -> list[tuple[str, ...]]: + """Parse the rendered board into its data rows. + + Args: + output: The captured stdout of ``goga topics board``. + columns: The text-column count of the table — 3 without ``--info``, + 4 with it (the todo column between branch and statuses). + + Returns: + The cell tuples of the data rows — the header and every divider row + (the header separator and the closing row dividers alike) dropped, + every cell stripped. + """ + # A divider row starts with "|-"; the header, data, and continuation + # rows start with "| " (a space follows the leading pipe). + lines = [line for line in output.splitlines() if line.startswith("| ")] + rows = [] + + for line in lines[1:]: + cells = line.split("|") + rows.append(tuple(cell.strip() for cell in cells[1 : columns + 1])) + + return rows + + +@requires_git +class TestTopicsBoard: + """``goga topics board --year Y`` over a real repository and scale.""" + + def test_board_renders_topics_statuses_and_current_marker( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The board table carries the topics, their artifact statuses, and + the current marker — exit 0.""" + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COLUMNS", "120") + + result = CliRunner().invoke(topics, ["--year", "2025", "board"]) + + assert result.exit_code == 0 + rows = _board_rows(result.output) + # feat-b reads from its ref tree (defined, the shallowest artifact); + # the current feat-a row reads the working copy (planned outranks + # prd.md); feat-b also hosts the shared feat-a topic of the year. + assert rows == [ + ("feat-b", "feat-b", "[defined]"), + ("* feat-a", "feat-a", "[planned]"), + ("feat-a", "feat-b", "[planned]"), + ] + # The remote-tracking twin collapsed into the local row. + assert "origin/feat-a" not in result.output + + def test_board_empty_year_prints_nothing_and_exits_zero( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A year without topics renders nothing — an empty board is not an + error.""" + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COLUMNS", "120") + + result = CliRunner().invoke(topics, ["--year", "2030", "board"]) + + assert result.exit_code == 0 + assert result.output == "" + + +class TestHistoryStatusToolFilter: + """``goga history status -s <tool>.<name>`` against the real assembly.""" + + def test_qualified_tool_status_validates_and_filters(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A registered tool status validates by its qualified name and + keeps exactly the topics carrying it. + + The enumeration is pinned before the first assembly and counts its + calls: the fake package is the only one read whatever the machine has + installed, and every CLI run assembles the registry exactly once. + """ + package = ModuleType("goga_tool_fake") + + def register_hooks(hooks: Any) -> None: + hooks.subscribe( + "statuses", + "register_statuses", + "published", + lambda context: context.register("published", "fake/published.md", after="planned"), + ) + + package.register_hooks = register_hooks + monkeypatch.setitem(sys.modules, "goga_tool_fake", package) + enumeration = mock.MagicMock(return_value={"goga_tool_fake": ["goga-tool-fake"]}) + monkeypatch.setattr("goga.hooks.tools.packages.packages_distributions", enumeration) + qualified = "fake.published" + artifact = "fake/published.md" + + # True registration: the qualified name assembles into the scale. + assert qualified in [stage.name for stage in assemble_status_scale().stages] + assert enumeration.call_count == 1 + + (tmp_path / ".goga/history/2026/demo-topic").mkdir(parents=True) + _write(tmp_path, f".goga/history/2026/demo-topic/{artifact}") + _write(tmp_path, ".goga/history/2026/other-topic/prd.md") + monkeypatch.chdir(tmp_path) + + filtered = CliRunner().invoke(history, ["-y", "2026", "status", "-s", qualified]) + unfiltered = CliRunner().invoke(history, ["-y", "2026", "status"]) + + assert filtered.exit_code == 0 + assert unfiltered.exit_code == 0 + # One build per run — the direct assembly plus one per CLI run. + assert enumeration.call_count == 3 + assert filtered.output == f"demo-topic [{qualified}]\n" + assert "other-topic" not in filtered.output + assert "other-topic [defined]" in unfiltered.output + + +class TestUnmigratedStatusCallback: + """A tool package still on the removed ``register_topic_statuses`` callback.""" + + def test_old_status_callback_is_silently_ignored( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """A package without ``register_hooks`` loses its statuses quietly. + + The old ``register_topic_statuses`` callback no longer exists and is + never called (ADR): the migrated package's statuses assemble into the + scale, the unmigrated one contributes nothing, and stderr carries no + error about it — the quiet loss the migration note of ``docs/tools.md`` + announces. + """ + + def register_hooks(hooks: Any) -> None: + hooks.subscribe( + "statuses", + "register_statuses", + "published", + lambda context: context.register("published", "published.md", after="planned"), + ) + + new_package = ModuleType("goga_tool_newtool") + new_package.register_hooks = register_hooks + old_package = ModuleType("goga_tool_oldtool") + old_package.register_topic_statuses = lambda _statuses: None + + monkeypatch.setitem(sys.modules, "goga_tool_newtool", new_package) + monkeypatch.setitem(sys.modules, "goga_tool_oldtool", old_package) + + with mock.patch( + "goga.hooks.tools.packages.packages_distributions", + return_value={ + "goga_tool_newtool": ["goga-tool-newtool"], + "goga_tool_oldtool": ["goga-tool-oldtool"], + }, + ): + names = [stage.name for stage in assemble_status_scale().stages] + + assert "newtool.published" in names + assert "oldtool.published" not in names + assert "oldtool" not in capsys.readouterr().err + + +class TestPipelineTopicProcedureRegression: + """The removed ``-b/--branch`` and the ``-t/--topic`` that replaced it.""" + + def test_pipeline_help_carries_topic_and_not_branch(self) -> None: + """``--help`` shows ``-t/--topic``; no ``-b`` option remains.""" + result = CliRunner().invoke(app, ["pipeline", "--help"]) + + assert result.exit_code == 0 + assert "-t" in result.output + assert "--topic" in result.output + assert "-b" not in result.output + + def test_pipeline_branch_option_is_rejected(self) -> None: + """``goga pipeline -b x dev`` fails at parsing — unknown option.""" + result = CliRunner().invoke(app, ["pipeline", "-b", "x", "dev"]) + + assert result.exit_code != 0 + assert "No such option" in result.output + assert "-b" in result.output + + +@requires_git +class TestSwitchTopicIdempotentChain: + """The switch chain of the domain over the real git cell.""" + + def test_switch_on_current_host_is_idempotent_without_mutations( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Already hosting the work returns the idempotent line — no + cleanliness probe, no git mutation.""" + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + + with ( + mock.patch.object(topics_switching, "is_working_tree_clean") as clean_probe, + mock.patch.object(topics_switching, "checkout_local_branch") as checkout, + mock.patch.object(topics_switching, "create_branch_from_remote_tracking") as create_branch, + ): + clean_probe.return_value = True + line = switch_topic("feat-a", year="2025") + + assert line == "Already on branch feat-a" + assert clean_probe.called is False + assert checkout.called is False + assert create_branch.called is False + + def test_switch_by_slug_checks_out_local_host(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A slug identifier resolves to its single local host and checks it out for real.""" + _init_topic_repo(tmp_path) + _add_solo_branch(tmp_path) + monkeypatch.chdir(tmp_path) + + line = switch_topic("solo", year="2025") + + assert line == "Switched to branch solo" + assert _current_branch(tmp_path) == "solo" + + def test_switch_by_branch_name_hosting_several_topics_switches( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An exact branch name is one candidate even when the branch hosts + several topics of the year — the switch needs no terminal.""" + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + line = switch_topic("feat-b", year="2025") + + assert line == "Switched to branch feat-b" + assert _current_branch(tmp_path) == "feat-b" + + def test_switch_ambiguous_slug_lists_candidates_without_terminal( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A slug hosted by several distinct branches is genuinely ambiguous — + the numbered list is the non-interactive error, and nothing is mutated.""" + _init_topic_repo(tmp_path) + _git(tmp_path, "switch", "-q", "-c", "one") + _write(tmp_path, ".goga/history/2025/shared/prd.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "topic shared") + _git(tmp_path, "switch", "-q", "-c", "two") + _git(tmp_path, "switch", "-q", "feat-a") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + with pytest.raises(click.ClickException, match=r"(?s)1\) one.*2\) two"): + switch_topic("shared", year="2025") + + assert _current_branch(tmp_path) == "feat-a" + + def test_switch_by_slug_with_pushed_twin_switches_and_is_idempotent( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A slug hosted by a branch and its pushed remote twin resolves to the + local branch — from another branch and again on the host.""" + _init_topic_repo(tmp_path) + _git(tmp_path, "switch", "-q", "--orphan", "work/x") + _write(tmp_path, ".goga/history/2025/work-x/plan.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "topic work-x") + _git(tmp_path, "update-ref", "refs/remotes/origin/work/x", "HEAD") + _git(tmp_path, "switch", "-q", "feat-a") + monkeypatch.chdir(tmp_path) + + line = switch_topic("work-x", year="2025") + idempotent = switch_topic("work-x", year="2025") + + assert line == "Switched to branch work/x" + assert idempotent == "Already on branch work/x" + assert _current_branch(tmp_path) == "work/x" + + def test_switch_by_slug_creates_branch_from_remote_tracking( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A slug hosted only by a remote-tracking ref creates the local branch from it.""" + _init_topic_repo(tmp_path) + _git(tmp_path, "switch", "-q", "feat-b") + _git(tmp_path, "branch", "-D", "feat-a") + monkeypatch.chdir(tmp_path) + + line = switch_topic("feat-a", year="2025") + + assert line == "Created branch feat-a from origin/feat-a" + assert _current_branch(tmp_path) == "feat-a" + + def test_switch_refuses_dirty_working_tree(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A dirty working tree is a clean error — the repository stays put.""" + _init_topic_repo(tmp_path) + _add_solo_branch(tmp_path) + (tmp_path / "uncommitted.txt").write_text("dirty\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + with pytest.raises(click.ClickException, match="dirty"): + switch_topic("solo", year="2025") + + assert _current_branch(tmp_path) == "feat-a" + + +@requires_git +class TestCreateTopicRealGit: + """``goga topics create`` over the real git cell and path routines.""" + + def test_create_topic_creates_branch_and_topic_directory( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Under the switch flag a free name creates the branch verbatim, + checks it out, and creates the topic directory of the year. + + The editor entry runs on the mocked terminal and is cancelled — + the no-op editor leaves the prefilled file untouched — so the + branch off HEAD, the checkout, and the directory are the outcome + and no todo.md is written. + """ + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + _export_editor(monkeypatch, tmp_path, "exit 0") + + line = create_topic("Feature/Foo_Bar", "HEAD", year="2025", switch=True) + + assert line == "Created branch Feature/Foo_Bar and topic 2025/feature-foo-bar" + assert _current_branch(tmp_path) == "Feature/Foo_Bar" + topic_dir = tmp_path / ".goga" / "history" / "2025" / "feature-foo-bar" + assert topic_dir.is_dir() + assert not (topic_dir / "todo.md").exists() + + def test_create_topic_default_path_plants_committed_branch( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The default path quarantines the topic into the branch — no switch. + + The branch carries exactly one commit over the base — the todo file + at the topic path, with the single trailing newline — while the + working copy, the index, and HEAD stay untouched: the current + branch keeps its name and no topic directory appears on disk. + """ + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + line = create_topic("Feature/Foo_Bar", "feat-a", todo="Payment retry", year="2025") + + assert line == "Created branch Feature/Foo_Bar and topic 2025/feature-foo-bar" + assert _current_branch(tmp_path) == "feat-a" + assert not (tmp_path / ".goga" / "history" / "2025" / "feature-foo-bar").exists() + # The stripped read keeps the exact-content check honest: only the + # single trailing newline is lost to the capture. + assert ( + _git_out(tmp_path, "show", "Feature/Foo_Bar:.goga/history/2025/feature-foo-bar/todo.md") == "Payment retry" + ) + # Exactly one commit over the base, planted without a switch. + assert _git_out(tmp_path, "rev-list", "--count", "feat-a..Feature/Foo_Bar") == "1" + + def test_create_topic_occupied_local_branch_errors_non_interactively( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An existing branch name is a clean occupancy error — nothing is created.""" + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + with pytest.raises(click.ClickException, match="already exists"): + create_topic("feat-b", "HEAD", year="2025") + + assert _current_branch(tmp_path) == "feat-a" + assert not (tmp_path / ".goga" / "history" / "2025" / "feat-b").exists() + + def test_create_topic_empty_todo_without_terminal_is_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An empty todo value counts as no value — without a terminal it + is a clean error and nothing is created.""" + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + with pytest.raises(click.ClickException, match="--todo"): + create_topic("feat-empty", "HEAD", todo="", year="2025") + + assert _current_branch(tmp_path) == "feat-a" + assert not (tmp_path / ".goga" / "history" / "2025" / "feat-empty").exists() + + +@requires_git +class TestTopicsBoardTodos: + """The todo column of ``goga topics board --info`` over real reads.""" + + def test_board_survives_hand_edited_non_utf8_todos(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Todo summaries outside UTF-8 render with the replacement character. + + Both todo.md reads — the working copy through pathlib and the ref + tree through ``git show`` — degrade the bytes instead of raising + through the clean-error boundary, or one hand-edited file would + break the whole board. The leading marker line never qualifies, so + the summary the board shows is the degraded line the normalization + picked. + """ + _init_topic_repo(tmp_path) + # The committed side: feat-b's todo lives in its ref tree only. + _git(tmp_path, "switch", "-q", "feat-b") + (tmp_path / ".goga" / "history" / "2025" / "feat-b" / "todo.md").write_bytes(b"###\nRem\xffote\n") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "feat-b todo") + _git(tmp_path, "switch", "-q", "feat-a") + # The uncommitted side: the current branch's working-copy todo. + (tmp_path / ".goga" / "history" / "2025" / "feat-a" / "todo.md").write_bytes(b"###\nPay\xffment\n") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COLUMNS", "120") + + result = CliRunner().invoke(topics, ["--year", "2025", "board", "--info"]) + + assert result.exit_code == 0 + assert "Pay�ment" in result.output + assert "Rem�ote" in result.output + + def test_create_todo_then_board_info_shows_summary_and_todo_status( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``create --todo`` and ``board --info`` close the loop over real git. + + The default creation commits the todo.md into the fresh branch — + verbatim plus one trailing newline, no working-copy directory — + the board reads the topic through it — the ``[todo]`` status — + and the summary column shows the first line the ``#``-marker + normalization qualifies, not the raw first line. The current + branch keeps its row and its asterisk; the fresh branch carries + the committed topic without a switch. + """ + _init_topic_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COLUMNS", "120") + + created = CliRunner().invoke( + topics, + [ + "--year", + "2025", + "create", + "feat-new", + "--from-current", + "--todo", + "###\n# Pay retry cap\n\nRetries ignore the cap.", + ], + ) + + assert created.exit_code == 0 + assert created.output == "Created branch feat-new and topic 2025/feat-new\n" + assert _current_branch(tmp_path) == "feat-a" + assert not (tmp_path / ".goga" / "history" / "2025" / "feat-new").exists() + # The stripped read keeps the exact-content check honest: only the + # single trailing newline is lost to the capture. + assert ( + _git_out(tmp_path, "show", "feat-new:.goga/history/2025/feat-new/todo.md") + == "###\n# Pay retry cap\n\nRetries ignore the cap." + ) + + result = CliRunner().invoke(topics, ["--year", "2025", "board", "--info"]) + + assert result.exit_code == 0 + assert ("feat-new", "feat-new", "Pay retry cap", "[todo]") in _board_rows(result.output, columns=4) + assert ("* feat-a", "feat-a", "", "[planned]") in _board_rows(result.output, columns=4) + + def test_board_old_title_txt_only_topic_is_empty_status( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A topic whose tree carries only the retired title.txt reads ``[empty]``. + + title.txt stopped being an axis artifact, so the topic has nothing + the scale recognizes — no status, no todo summary — the clean break + over real git, with the legacy file left on disk untouched. + """ + _init_topic_repo(tmp_path) + _git(tmp_path, "switch", "-q", "-c", "legacy") + legacy_file = tmp_path / ".goga" / "history" / "2025" / "legacy-work" / "title.txt" + legacy_file.parent.mkdir(parents=True) + legacy_file.write_text("Retired artifact\n", encoding="utf-8") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "legacy topic") + _git(tmp_path, "switch", "-q", "feat-a") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COLUMNS", "120") + + result = CliRunner().invoke(topics, ["--year", "2025", "board", "--info"]) + + assert result.exit_code == 0 + assert ("legacy-work", "legacy", "", "[empty]") in _board_rows(result.output, columns=4) + # The legacy file stays byte-exact in its ref tree — the board read + # it and dropped it as an unknown artifact, it never rewrote it. + assert _git_out(tmp_path, "show", "legacy:.goga/history/2025/legacy-work/title.txt") == "Retired artifact" + + +@requires_git +class TestHistoryPrune: + """``goga history prune`` over the real branch inventory and tree.""" + + def test_prune_over_real_git_deletes_orphans_keeps_hosted( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The real ``for-each-ref`` inventory protects hosted topics; the orphans go.""" + _init_topic_repo(tmp_path) + # The switch back onto feat-a removed feat-b's directory from the + # working tree — restore it untracked, so both hosted topics are + # live candidates the real inventory must protect. + _write(tmp_path, ".goga/history/2025/feat-b/prd.md") + _write(tmp_path, ".goga/history/2025/orphan-c/prd.md") + _write(tmp_path, ".goga/history/2025/done-d/completed/plan.md") + monkeypatch.chdir(tmp_path) + + dry = CliRunner().invoke(history, ["-y", "2025", "prune", "--dry-run"]) + + assert dry.exit_code == 0 + assert dry.output.splitlines() == ["done-d", "orphan-c"] + assert (tmp_path / ".goga/history/2025/orphan-c/prd.md").exists() + + wet = CliRunner().invoke(history, ["-y", "2025", "prune"]) + + assert wet.exit_code == 0 + assert wet.output.splitlines() == ["done-d", "orphan-c"] + assert not (tmp_path / ".goga/history/2025/orphan-c").exists() + assert not (tmp_path / ".goga/history/2025/done-d").exists() + # The branch-hosted topics survive; a done orphan goes regardless. + assert (tmp_path / ".goga/history/2025/feat-a").is_dir() + assert (tmp_path / ".goga/history/2025/feat-b").is_dir() + + def test_prune_remote_only_host_protects_over_real_git( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A remote-tracking ref alone protects its topic — no local branch needed.""" + _init_topic_repo(tmp_path) + # A throwaway branch supplies the tree, then keeps only its remote twin. + _git(tmp_path, "switch", "-q", "-c", "throwaway") + _write(tmp_path, ".goga/history/2025/remote-only/prd.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "remote-only topic") + _git(tmp_path, "update-ref", "refs/remotes/origin/remote-only", "HEAD") + _git(tmp_path, "switch", "-q", "feat-a") + _git(tmp_path, "branch", "-qD", "throwaway") + # The topic directory is present in the working tree, untracked. + _write(tmp_path, ".goga/history/2025/remote-only/prd.md") + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(history, ["-y", "2025", "prune", "--dry-run"]) + + assert result.exit_code == 0 + assert result.output == "" + assert (tmp_path / ".goga/history/2025/remote-only/prd.md").exists() + + +@requires_git +class TestPublishTopicRealGit: + """``publish_topic`` over the real git cell — the quarantined fast path. + + No domain routine and no git routine is mocked: the CLI-less scenarios + drive the whole chain ``publish_topic`` → ``goga.topics.git`` → real + git against a throwaway repository with a real bare ``origin``. + """ + + def test_publish_end_to_end_leaves_user_state_untouched( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A publish over a dirty tree leaves HEAD, the status, and the + working copy identical — the topic exists only in the pushed branch.""" + _init_publish_repo(tmp_path) + # The uncommitted modification the quarantine invariant must survive. + (tmp_path / "tracked.txt").write_text("wip\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + year = current_year() + before = ( + _git_out(tmp_path, "rev-parse", "HEAD"), + _git_out(tmp_path, "status", "--porcelain"), + _worktree_snapshot(tmp_path), + ) + + line = publish_topic("Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}") + + after = ( + _git_out(tmp_path, "rev-parse", "HEAD"), + _git_out(tmp_path, "status", "--porcelain"), + _worktree_snapshot(tmp_path), + ) + assert before == after + # The topic directory exists in the pushed branch, never on disk. + assert not (tmp_path / ".goga").exists() + assert line == f"Created branch Feature/Foo_Bar and published topic {year}/feature-foo-bar" + assert "\n" not in line + + def test_publish_creates_single_todo_commit_and_shows_on_remote_board( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The published branch carries exactly the todo commit — upstream + bound to origin and visible on the remote board with the ``todo`` status.""" + _init_publish_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COLUMNS", "120") + year = current_year() + topic_path = f".goga/history/{year}/feature-foo-bar/todo.md" + + publish_topic("Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}") + + assert _git_out(tmp_path, "rev-list", "--count", "origin/main..Feature/Foo_Bar") == "1" + assert _git_out(tmp_path, "show", "--name-only", "--format=", "Feature/Foo_Bar").splitlines() == [topic_path] + assert ( + _git_out(tmp_path, "show", "-s", "--format=%s", "Feature/Foo_Bar") == "goga: create topic feature-foo-bar" + ) + assert _git_out(tmp_path, "show", f"Feature/Foo_Bar:{topic_path}") == "Payment retry" + assert _git_out(tmp_path, "config", "branch.Feature/Foo_Bar.remote") == "origin" + # The local branch stays after the push. + assert _git_out(tmp_path, "rev-parse", "--verify", "refs/heads/Feature/Foo_Bar") + _git(tmp_path, "fetch", "-q", "origin") + + result = CliRunner().invoke(topics, ["--year", year, "board", "--remote", "--info"]) + + assert result.exit_code == 0 + assert _board_rows(result.output, columns=4) == [ + ("feature-foo-bar", "origin/Feature/Foo_Bar", "Payment retry", "[todo]") + ] + + def test_publish_failed_push_rolls_back_and_rerun_succeeds( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failed push deletes the planted branch and disturbs nothing — + the freed name carries the immediate re-run of the same cycle.""" + origin = _init_publish_repo(tmp_path) + _git(tmp_path, "remote", "set-url", "--push", "origin", "../does-not-exist.git") + monkeypatch.chdir(tmp_path) + porcelain_before = _git_out(tmp_path, "status", "--porcelain") + + with pytest.raises(click.ClickException, match="git failed:"): + publish_topic("Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}") + + assert "Feature/Foo_Bar" not in _git_out(tmp_path, "for-each-ref", "refs/heads") + assert _git_out(tmp_path, "status", "--porcelain") == porcelain_before + + _git(tmp_path, "remote", "set-url", "--push", "origin", str(origin)) + line = publish_topic("Feature/Foo_Bar", "Payment retry", "origin/main", "goga: create topic {slug}") + + assert "refs/heads/Feature/Foo_Bar" in _git_out(tmp_path, "for-each-ref", "refs/heads") + assert _git_out(tmp_path, "rev-parse", "--verify", "refs/remotes/origin/Feature/Foo_Bar") + assert "published topic" in line + + def test_publish_non_ascii_todo_survives_utf8(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A non-ASCII todo round-trips byte-exact — UTF-8 with one + trailing newline, in the branch tree and on the remote board.""" + _init_publish_repo(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COLUMNS", "120") + year = current_year() + topic_path = f".goga/history/{year}/feature-foo-bar/todo.md" + + publish_topic("Feature/Foo_Bar", "Оплата повторно", "origin/main", "goga: create topic {slug}") + + shown = subprocess.run( + ["git", "show", f"Feature/Foo_Bar:{topic_path}"], + cwd=tmp_path, + check=True, + capture_output=True, + ) + assert shown.stdout.decode("utf-8") == "Оплата повторно\n" + assert shown.stdout == "Оплата повторно\n".encode("utf-8") # noqa: UP012 — the codec is the contract + assert len(shown.stdout) == 30 + _git(tmp_path, "fetch", "-q", "origin") + + result = CliRunner().invoke(topics, ["--year", year, "board", "--remote", "--info"]) + + assert result.exit_code == 0 + assert "Оплата" in result.output + assert ("feature-foo-bar", "origin/Feature/Foo_Bar", "Оплата повторно", "[todo]") in _board_rows( + result.output, columns=4 + ) + + def test_publish_slug_hosted_by_another_branch_is_blocked( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The branch-tree oracle over real git: a slug already hosted by + another branch blocks the publish — nothing is planted or pushed.""" + _init_publish_repo(tmp_path) + year = current_year() + _git(tmp_path, "switch", "-q", "-c", "Host_Branch") + _write(tmp_path, f".goga/history/{year}/feature-foo-bar/prd.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "host topic") + _git(tmp_path, "switch", "-q", "main") + monkeypatch.chdir(tmp_path) + heads_before = _git_out(tmp_path, "for-each-ref", "refs/heads") + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") + + assert raised.value.message == ( + f"topic 'feature-foo-bar' of {year} is already hosted by branch 'Host_Branch'" + " — run 'goga topics board' to see the board" + ) + assert _git_out(tmp_path, "for-each-ref", "refs/heads") == heads_before + assert "Feature/Foo_Bar" not in _git_out(tmp_path, "for-each-ref", "refs/heads") + + def test_publish_from_a_subdirectory_still_sees_the_branch_tree_conflict( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The branch-tree oracle reads the root tree from any directory. + + The publication write is root-relative (``--cacheinfo`` stages at + the repository root regardless of the working directory), so the + oracle must probe the same tree from a subdirectory — a bare + pathspec resolves against the working directory there and the probe + would read nothing, publishing a duplicate of an already-hosted + topic to origin. + """ + _init_publish_repo(tmp_path) + year = current_year() + _git(tmp_path, "switch", "-q", "-c", "Host_Branch") + _write(tmp_path, f".goga/history/{year}/feature-foo-bar/prd.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "host topic") + _git(tmp_path, "switch", "-q", "main") + heads_before = _git_out(tmp_path, "for-each-ref", "refs/heads") + nested = tmp_path / "nested" + nested.mkdir() + monkeypatch.chdir(nested) + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") + + assert raised.value.message == ( + f"topic 'feature-foo-bar' of {year} is already hosted by branch 'Host_Branch'" + " — run 'goga topics board' to see the board" + ) + assert _git_out(tmp_path, "for-each-ref", "refs/heads") == heads_before + assert "Feature/Foo_Bar" not in _git_out(tmp_path, "for-each-ref", "refs/heads") + assert "Feature/Foo_Bar" not in _git_out(tmp_path, "ls-remote", "--heads", "origin") + + def test_publish_sibling_slug_is_not_a_conflict(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A sibling slug sharing only the prefix text stays free. + + ``feature-foo-bar`` hosted by another branch must not block + ``feature-foo`` — the trailing slash of the probe prefix against + real git pathspec filtering, not an emulated reader. + """ + _init_publish_repo(tmp_path) + year = current_year() + _git(tmp_path, "switch", "-q", "-c", "Host_Branch") + _write(tmp_path, f".goga/history/{year}/feature-foo-bar/prd.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "host sibling topic") + _git(tmp_path, "switch", "-q", "main") + monkeypatch.chdir(tmp_path) + + line = publish_topic("Feature/Foo", "T", "origin/main", "m") + + assert line == f"Created branch Feature/Foo and published topic {year}/feature-foo" + + def test_publish_slug_hosted_only_on_origin_is_blocked( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A slug hosted only by a remote-tracking ref blocks the publish. + + The name differs from the remote ref's short name, so the branch + oracle stays free and the conflict comes from the branch-tree oracle + alone — a topic hosted only on ``origin`` blocks the slug. + """ + _init_publish_repo(tmp_path) + year = current_year() + _git(tmp_path, "switch", "-q", "-c", "throwaway") + _write(tmp_path, f".goga/history/{year}/remote-only/prd.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "remote-only topic") + _git(tmp_path, "update-ref", "refs/remotes/origin/Remote_Only", "HEAD") + _git(tmp_path, "switch", "-q", "main") + _git(tmp_path, "branch", "-qD", "throwaway") + monkeypatch.chdir(tmp_path) + + with pytest.raises(click.ClickException) as raised: + publish_topic("Remote/Only", "T", "origin/main", "m") + + assert raised.value.message == ( + f"topic 'remote-only' of {year} is already hosted by branch 'origin/Remote_Only'" + " — run 'goga topics board' to see the board" + ) + + def test_publish_name_the_oracle_misses_never_deletes_real_work( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An occupied name the inventory oracle cannot see is stopped by the + create-only plant — the pre-existing branch survives. + + A tag of the same name makes git lengthen the display name of + ``refs/heads/v1`` to ``heads/v1``, so no oracle reports the name + occupied. A plant that moved the ref would push the new commit over + ``v1`` and then delete the branch on the push failure — the real + commit lost behind the push error. The plant refuses instead. + """ + _init_publish_repo(tmp_path) + _git(tmp_path, "branch", "v1") + _git(tmp_path, "tag", "v1") + before = _git_out(tmp_path, "rev-parse", "refs/heads/v1") + monkeypatch.chdir(tmp_path) + + with pytest.raises(click.ClickException, match="reference already exists"): + publish_topic("v1", "Todo", "origin/main", "goga: create topic {slug}") + + assert _git_out(tmp_path, "rev-parse", "refs/heads/v1") == before + assert "refs/remotes/origin/v1" not in _git_out(tmp_path, "for-each-ref", "refs/remotes/origin") + + def test_publish_non_utf8_remote_output_rolls_back_and_surfaces_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A remote message outside UTF-8 neither pierces the boundary nor + strands the planted branch. + + The push of a rejecting ``pre-receive`` hook answers with bytes a + strict UTF-8 reader cannot decode — a ``UnicodeDecodeError`` is a + ``ValueError``, it matches no handler of the domain, so strict + decoding would pierce the clean-error boundary *and* skip the + rollback. The replacement-character decoding keeps both guarantees. + """ + origin = _init_publish_repo(tmp_path) + hook = origin / "hooks" / "pre-receive" + hook.write_text('#!/bin/sh\nprintf "erreur: \\377\\376\\n" >&2\nexit 1\n') + hook.chmod(0o755) + monkeypatch.chdir(tmp_path) + + with pytest.raises(click.ClickException, match="remote rejected"): + publish_topic("Feature/Foo_Bar", "Todo", "origin/main", "goga: create topic {slug}") + + # The planted branch was rolled back — nothing of the cycle survives. + assert "refs/heads/Feature/Foo_Bar" not in _git_out(tmp_path, "for-each-ref", "refs/heads") + assert not (tmp_path / ".goga").exists() + + def test_publish_newline_in_name_cannot_inject_a_second_command( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A newline inside the name never rewrites another ref. + + The line-oriented ``update-ref --stdin`` stream splits commands on + ``LF``, so a machine-generated name carrying ``... <oid> LF update + refs/heads/main`` would open a second command of the transaction and + silently move the user's ``main`` behind a garbled error. The NUL + framing keeps the verbatim name one token, so git's own refname + validation rejects it as one clean error before any mutation. + """ + _init_publish_repo(tmp_path) + monkeypatch.chdir(tmp_path) + base = _git_out(tmp_path, "rev-parse", "HEAD") + injected = f"evil {base}\nupdate refs/heads/main" + + with pytest.raises(click.ClickException, match="invalid ref format"): + publish_topic(injected, "Todo", "origin/main", "goga: create topic {slug}") + + assert _git_out(tmp_path, "rev-parse", "refs/heads/main") == base + assert _git_out(tmp_path, "for-each-ref", "--format=%(refname)", "refs/heads") == "refs/heads/main" + assert "refs/remotes/origin/evil" not in _git_out(tmp_path, "for-each-ref", "refs/remotes/origin") + + def test_publish_empty_template_does_not_wait_for_stdin( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An explicitly empty commit template publishes without waiting on stdin. + + ``commit-tree -m ""`` reads its message from stdin when the ``-m`` + argument is empty, and the runner used to leave that stdin + inherited from the caller: under a terminal or a harness-held open + pipe — neither ever reaching EOF — the publish hung forever with no + output. The cycle must complete — the empty template becomes the + commit message verbatim while the non-empty todo still carries the + todo.md payload of the published branch. + """ + _init_publish_repo(tmp_path) + monkeypatch.chdir(tmp_path) + year = current_year() + + # fd 0 becomes a pipe nobody writes to — EOF never arrives, the + # exact descriptor a terminal or a held stdin provides. The cycle + # runs on a thread so a regression surfaces as this test's failure + # instead of a timeout of the whole session. + read_fd, write_fd = os.pipe() + saved_stdin = os.dup(0) + lines: list[str] = [] + failure: list[BaseException] = [] + + def cycle() -> None: + try: + os.dup2(read_fd, 0) + lines.append(publish_topic("Feature/Foo_Bar", "Payment retry", "origin/main", "")) + except BaseException as exc: # recorded, re-raised on the main thread below + failure.append(exc) + finally: + os.dup2(saved_stdin, 0) + + thread = threading.Thread(target=cycle, daemon=True) + thread.start() + thread.join(timeout=30) + alive = thread.is_alive() + os.close(read_fd) + os.close(write_fd) + os.close(saved_stdin) + + assert not alive, ( + "publish_topic with an empty template never returned — a git invocation is waiting on the caller's stdin" + ) + if failure: + raise failure[0] + assert lines[0] == (f"Created branch Feature/Foo_Bar and published topic {year}/feature-foo-bar") + # The published commit carries the empty message verbatim. + assert _git_out(tmp_path, "show", "-s", "--format=%s", "Feature/Foo_Bar") == "" + assert ( + _git_out(tmp_path, "show", f"Feature/Foo_Bar:.goga/history/{year}/feature-foo-bar/todo.md") + == "Payment retry" + ) + + +def _init_delete_repo(root: Path) -> Path: + """Build the throwaway repository the deletion scenarios share. + + The ``_init_publish_repo`` base (``main`` over a bare ``origin``), + plus a ``Feature/Foo_Bar`` branch hosting the ``feature-foo-bar`` + topic of the current year committed and pushed — the origin twin — + the checkout back onto ``main``, and the topic directory re-created + on disk untracked: the full three-part target of the deletion flow + (local branch, origin twin, directory). + + Args: + root: The empty directory the repository is built in. + + Returns: + The path of the bare origin repository. + """ + origin = _init_publish_repo(root) + year = current_year() + _git(root, "switch", "-q", "-c", "Feature/Foo_Bar") + _write(root, f".goga/history/{year}/feature-foo-bar/todo.md") + _git(root, "add", ".goga") + _git(root, *_GIT_IDENTITY, "commit", "-qm", "topic feature-foo-bar") + _git(root, "push", "-q", "origin", "Feature/Foo_Bar") + _git(root, "switch", "-q", "main") + _write(root, f".goga/history/{year}/feature-foo-bar/todo.md") + return origin + + +@requires_git +class TestDeleteTopicsRealGit: + """``resolve_delete_targets``/``delete_topics`` over the real git cell. + + No domain routine and no git routine is mocked: the scenarios drive + the whole resolution → removal chain against a throwaway repository + with a real bare ``origin`` — the tier reading over real + ``for-each-ref`` display names, the twin collapse, the merged-work + and current-branch guards, and the capture-before-delete / + restore-on-failure dance of a rejected remote deletion. + """ + + def test_delete_end_to_end_removes_branch_twin_and_directory( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The full three-part deletion: local branch, origin twin, directory.""" + origin = _init_delete_repo(tmp_path) + monkeypatch.chdir(tmp_path) + year = current_year() + + targets = resolve_delete_targets(["feature-foo-bar"], year=year) + + assert targets == [ + DeleteTarget( + topic="feature-foo-bar", + branch="Feature/Foo_Bar", + remote="Feature/Foo_Bar", + has_dir=True, + ) + ] + + line = delete_topics(targets, year=year) + + assert line == f"Deleted 1 topic(s) of {year}: feature-foo-bar" + assert _git_out(tmp_path, "for-each-ref", "--format=%(refname)", "refs/heads") == "refs/heads/main" + # The delete push also drops the local remote-tracking twin. + assert ( + _git_out(tmp_path, "for-each-ref", "--format=%(refname)", "refs/remotes/origin") + == "refs/remotes/origin/main" + ) + assert not (tmp_path / ".goga" / "history" / year / "feature-foo-bar").exists() + # The bare origin truly lost the branch. + assert _git_out(origin, "for-each-ref", "--format=%(refname)", "refs/heads") == "refs/heads/main" + + def test_delete_failed_remote_restores_local_at_the_captured_commit( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A rejected remote deletion restores the local branch exactly — + the twin and the directory untouched, the push reason surfaced.""" + _init_delete_repo(tmp_path) + _git(tmp_path, "remote", "set-url", "--push", "origin", "../does-not-exist.git") + monkeypatch.chdir(tmp_path) + year = current_year() + commit = _git_out(tmp_path, "rev-parse", "refs/heads/Feature/Foo_Bar") + targets = resolve_delete_targets(["feature-foo-bar"], year=year) + + with pytest.raises(click.ClickException, match="git failed:"): + delete_topics(targets, year=year) + + assert _git_out(tmp_path, "rev-parse", "refs/heads/Feature/Foo_Bar") == commit + assert _git_out(tmp_path, "rev-parse", "--verify", "refs/remotes/origin/Feature/Foo_Bar") + assert (tmp_path / ".goga" / "history" / year / "feature-foo-bar").exists() + + def test_delete_merged_work_is_an_error_over_real_refs( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A topic merged into ``main`` with its own branch gone is merged + work — the integration branch is never the deletion target.""" + _init_publish_repo(tmp_path) + year = current_year() + _git(tmp_path, "switch", "-q", "-c", "feature-x") + _write(tmp_path, f".goga/history/{year}/feature-x/prd.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "topic feature-x") + _git(tmp_path, "switch", "-q", "main") + _git(tmp_path, "merge", "-q", "--ff-only", "feature-x") + _git(tmp_path, "branch", "-q", "-D", "feature-x") + heads_before = _git_out(tmp_path, "for-each-ref", "--format=%(refname)", "refs/heads") + monkeypatch.chdir(tmp_path) + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-x"], year=year) + + assert "feature-x" in raised.value.message + assert "main" in raised.value.message + assert _git_out(tmp_path, "for-each-ref", "--format=%(refname)", "refs/heads") == heads_before + + def test_delete_current_branch_hosting_target_is_an_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Sitting on the hosting branch blocks the deletion — switch away.""" + _init_publish_repo(tmp_path) + year = current_year() + _git(tmp_path, "switch", "-q", "-c", "Feature/Foo_Bar") + _write(tmp_path, f".goga/history/{year}/feature-foo-bar/todo.md") + _git(tmp_path, "add", ".goga") + _git(tmp_path, *_GIT_IDENTITY, "commit", "-qm", "topic feature-foo-bar") + monkeypatch.chdir(tmp_path) + + with pytest.raises(click.ClickException, match="switch away"): + resolve_delete_targets(["feature-foo-bar"], year=year) + + assert _git_out(tmp_path, "rev-parse", "--verify", "refs/heads/Feature/Foo_Bar") + + def test_delete_cli_round_trip_with_yes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The CLI surface round-trips: resolution, skipped confirmation, + removal, one result line — and without ``--yes`` a non-terminal + is a clean error before anything is deleted.""" + _init_delete_repo(tmp_path) + monkeypatch.chdir(tmp_path) + year = current_year() + + declined = CliRunner().invoke(topics, ["--year", year, "delete", "feature-foo-bar"]) + + assert declined.exit_code == 1 + assert "interactive terminal" in declined.output + assert _git_out(tmp_path, "rev-parse", "--verify", "refs/heads/Feature/Foo_Bar") + + result = CliRunner().invoke(topics, ["--year", year, "delete", "feature-foo-bar", "--yes"]) + + assert result.exit_code == 0 + assert result.output == f"Deleted 1 topic(s) of {year}: feature-foo-bar\n" + assert "refs/heads/Feature/Foo_Bar" not in _git_out( + tmp_path, "for-each-ref", "--format=%(refname)", "refs/heads" + ) + + def test_delete_unpublished_topic_by_exact_name_over_real_git( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A freshly created, unpublished topic deletes by its exact name. + + ``create_topic`` under the switch flag leaves the todo.md + uncommitted, so the branch is bare of the topic and only the disk + directory carries it — the exact-name identifier, which the + exact-branch tier matches as a bare branch, must still reach the + disk topic. The bare branch itself stays: deletion deletes + topics, not bare branches. + """ + _init_publish_repo(tmp_path) + monkeypatch.chdir(tmp_path) + year = current_year() + create_topic("feature-foo", "main", todo="the plan", year=year, switch=True) + _git(tmp_path, "switch", "-q", "main") + + targets = resolve_delete_targets(["feature-foo"], year=year) + + assert targets == [DeleteTarget(topic="feature-foo", branch=None, remote=None, has_dir=True)] + line = delete_topics(targets, year=year) + + assert line == f"Deleted 1 topic(s) of {year}: feature-foo" + assert not (tmp_path / ".goga" / "history" / year / "feature-foo").exists() + # The bare branch stays — it hosts no topic. + assert _git_out(tmp_path, "rev-parse", "--verify", "refs/heads/feature-foo") diff --git a/tests/onboarding/test_answers.py b/tests/onboarding/test_answers.py index 71e95ccd..629fac83 100644 --- a/tests/onboarding/test_answers.py +++ b/tests/onboarding/test_answers.py @@ -88,6 +88,7 @@ def test_goga_config_answers_is_frozen(self) -> None: image="qarium/goga-python-3.12:0.1", pipeline_agent="claude", ) + with pytest.raises(dataclasses.FrozenInstanceError): cfg.language = "go" # type: ignore[misc] @@ -99,6 +100,7 @@ def test_init_answers_is_frozen(self) -> None: pipeline_agent="claude", ) answers = InitAnswers(goga_config=cfg) + with pytest.raises(dataclasses.FrozenInstanceError): answers.goga_config = cfg # type: ignore[misc] @@ -149,6 +151,7 @@ def test_init_answers_kw_only(self) -> None: image="qarium/goga-python-3.12:0.1", pipeline_agent="claude", ) + with pytest.raises(TypeError): InitAnswers(cfg) # type: ignore[call-arg] diff --git a/tests/onboarding/test_generator.py b/tests/onboarding/test_generator.py index bd462dd5..d623f719 100644 --- a/tests/onboarding/test_generator.py +++ b/tests/onboarding/test_generator.py @@ -102,6 +102,7 @@ def test_generate_goga_config_yaml_compatible_with_load_config(self, tmp_path: P mock_response.raise_for_status = MagicMock() gen = self._make_gen(tmp_path) + with patch("goga.onboarding.generator.requests.get", return_value=mock_response): gen.generate_goga_config(config) @@ -167,6 +168,7 @@ def test_generate_golang_language_url(self, tmp_path: Path) -> None: mock_response.raise_for_status = MagicMock() gen = self._make_gen(tmp_path) + with patch("goga.onboarding.generator.requests.get", return_value=mock_response) as mock_get: gen.generate(answers) @@ -179,6 +181,7 @@ def test_generate_skips_convention_when_no_usages(self, tmp_path: Path) -> None: answers = InitAnswers(goga_config=config) gen = self._make_gen(tmp_path) + with patch("goga.onboarding.generator.requests.get") as mock_get: gen.generate(answers) @@ -198,6 +201,7 @@ def test_generate_skips_convention_when_usages_without_conventions_key(self, tmp answers = InitAnswers(goga_config=config) gen = self._make_gen(tmp_path) + with patch("goga.onboarding.generator.requests.get") as mock_get: gen.generate(answers) @@ -224,6 +228,7 @@ def test_generate_creates_usages_directory_for_convention(self, tmp_path: Path) mock_response.raise_for_status = MagicMock() gen = self._make_gen(tmp_path) + with patch("goga.onboarding.generator.requests.get", return_value=mock_response): gen.generate(answers) @@ -241,6 +246,7 @@ def test_generate_convention_download_fails_propagates(self, tmp_path: Path) -> answers = InitAnswers(goga_config=config) gen = self._make_gen(tmp_path) + with ( patch( "goga.onboarding.generator.requests.get", @@ -320,6 +326,7 @@ def test_generator_no_dockerfile_when_none(self, tmp_path: Path) -> None: answers = InitAnswers(goga_config=config) gen = self._make_gen(tmp_path) + with patch("goga.onboarding.generator.requests.get"): gen.generate(answers) @@ -439,6 +446,7 @@ def test_generate_goga_config_emits_yaml_in_correct_order(self, tmp_path: Path) gen = FileGenerator() gen._base_dir = tmp_path + with patch("goga.onboarding.generator.requests.get", return_value=mock_response): gen.generate_goga_config(config) @@ -465,6 +473,7 @@ def test_generate_goga_config_emits_dockerfile_in_full_canonical_order(self, tmp gen = FileGenerator() gen._base_dir = tmp_path + with patch("goga.onboarding.generator.requests.get", return_value=mock_response): gen.generate_goga_config(config) diff --git a/tests/onboarding/test_questionnaire.py b/tests/onboarding/test_questionnaire.py index ad3258e8..3622f902 100644 --- a/tests/onboarding/test_questionnaire.py +++ b/tests/onboarding/test_questionnaire.py @@ -425,14 +425,14 @@ def test_all_languages_have_image_map_entries(self) -> None: for language in _LANGUAGES: assert language in _IMAGE_MAP, f"Language '{language}' missing from _IMAGE_MAP" - def test_image_map_defaults_use_version_1_2(self) -> None: - """All suggested Docker images use the current default tag `:1.2`.""" + def test_image_map_defaults_use_version_1_3(self) -> None: + """All suggested Docker images use the current default tag `:1.3`.""" from goga.onboarding.questionnaire import _IMAGE_MAP for language, images in _IMAGE_MAP.items(): assert images, f"Language '{language}' has no image entries" for image in images: - assert image.endswith(":1.2"), f"Image '{image}' for language '{language}' must use the :1.2 tag" + assert image.endswith(":1.3"), f"Image '{image}' for language '{language}' must use the :1.3 tag" def test_questionnaire_ask_goga_config_duplicate_usage_name_skipped(self) -> None: prompts = iter( @@ -827,6 +827,7 @@ def test_agent_choice_accepts_all_supported_agents(self) -> None: from goga.onboarding.questionnaire import _AGENTS choice = Choice(_AGENTS) + for agent in ("claude", "codex", "cursor", "opencode", "qwen"): assert choice.convert(agent, None, None) == agent diff --git a/tests/pipeline/compiler/test_compile_flow.py b/tests/pipeline/compiler/test_compile_flow.py index 62538e00..161f5984 100644 --- a/tests/pipeline/compiler/test_compile_flow.py +++ b/tests/pipeline/compiler/test_compile_flow.py @@ -146,14 +146,16 @@ def test_private_helpers_not_on_facade(self) -> None: assert "_CANONICAL_KEY_ORDER" not in facade_all def test_canonical_fields_signature_has_stage_name(self) -> None: - """``_canonical_fields`` takes ``(body, stage_name)`` for the mutual-exclusion message.""" + """``_canonical_fields`` takes ``(body, stage_name, notes, memory_fields)`` — both optional.""" import inspect from goga.pipeline.compiler.compile_flow import _canonical_fields parameters = list(inspect.signature(_canonical_fields).parameters) - assert parameters == ["body", "stage_name"] + assert parameters == ["body", "stage_name", "notes", "memory_fields"] + assert inspect.signature(_canonical_fields).parameters["notes"].default is None + assert inspect.signature(_canonical_fields).parameters["memory_fields"].default is None def test_canonical_key_order_includes_approve_and_script_slots(self) -> None: """The extended canonical order slots ``auto_approve`` and the script_* keys.""" @@ -161,11 +163,23 @@ def test_canonical_key_order_includes_approve_and_script_slots(self) -> None: # ``auto_approve`` immediately follows ``interactive``; the script_* # family trails ``skills`` in authored order (before/script/after) and - # closes with the translated ``script_timeout``. + # closes with the translated ``script_timeout``, which the two memory + # stage keys follow. assert "auto_approve" in _CANONICAL_KEY_ORDER assert _CANONICAL_KEY_ORDER.index("auto_approve") == _CANONICAL_KEY_ORDER.index("interactive") + 1 - assert _CANONICAL_KEY_ORDER[-4:] == ["script_before", "script", "script_after", "script_timeout"] + assert _CANONICAL_KEY_ORDER[-6:] == [ + "script_before", + "script", + "script_after", + "script_timeout", + "reflect", + "memory_use", + ] + assert _CANONICAL_KEY_ORDER.index("reflect") == _CANONICAL_KEY_ORDER.index("script_timeout") + 1 assert _CANONICAL_KEY_ORDER.index("skills") < _CANONICAL_KEY_ORDER.index("script_before") + # ``buttons`` — the compiled form of the workflow notes instruction — + # occupies the canonical slot immediately after ``description``. + assert _CANONICAL_KEY_ORDER.index("buttons") == _CANONICAL_KEY_ORDER.index("description") + 1 def test_approve_sentinel_constant_exists(self) -> None: """The ``_APPROVE_SENTINEL`` constant (approve directive plumbing) exists.""" diff --git a/tests/pipeline/compiler/test_compile_flow_memory.py b/tests/pipeline/compiler/test_compile_flow_memory.py new file mode 100644 index 00000000..a33f4e67 --- /dev/null +++ b/tests/pipeline/compiler/test_compile_flow_memory.py @@ -0,0 +1,564 @@ +"""Contract and logic tests for the ``compile_flow`` memory emission (step 4.9). + +Covers the memory half of the compiler: when the supplied workflow carries +memory participation, ``compile_flow`` assembles the top-level ``memory`` block +of the flow-file and the per-stage memory keys — + +- the block is emitted if and only if at least one stage participates: a + ``reflect`` instruction under the reflect method (the default), or a true + ``memory`` instruction under the alignment method; a memory configuration + alone never turns the block on (silent no-op, cases 3 and 5); +- participation is counted over the WORKING body — after skip removal and loop + expansion, embedded extend-stages included (a skipped stage's instructions + never count); +- the emitted path composes the fixed memory root ``.goga/memory`` with the + authored suffix (the bare root when the suffix is ``None``); +- reflect method — ``mode: r`` and ``memory_use: false`` (read-only project + memory, no global participation); alignment method — ``mode`` the + materialized authored value (``rw`` by default) and ``memory_use: false``; +- reflect method — a participating stage carries ``reflect: {file, mode}`` + (file verbatim, mode materialized); alignment method — EVERY stage carries + ``memory_use`` (explicit ``false`` on every non-participating one, because + afm's ``UseFor(stage)`` inherits the global default for an unset key); +- both keys occupy the canonical slots immediately after ``script_timeout`` and + are uniform across every loop-expanded copy; +- the goga-side method selector never reaches the output; +- an authoring ``reflect`` / ``memory_use`` key in a stage body (pipeline-file + stage OR embedded extend-stage body) is a structural error; +- a workflow without memory participation compiles byte-identically to the + current output (no block, no stage keys); +- output-side only — the ``PipelineDocument`` mirror stays the faithful mirror + of the source pipeline-file. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import re +from pathlib import Path + +import pytest +from goga.pipeline.compiler import ( + FlowDocument, + FlowMemory, + PipelineDocument, + StructuralError, + compile_flow, +) +from goga.pipeline.compiler.compile_flow import ( + _CANONICAL_KEY_ORDER, + _assemble_memory_keys, + _canonical_fields, + _effective_overrides, + _memory_emission, + _MemoryEmission, +) +from goga.pipeline.workflow import ( + WorkflowDocument, + WorkflowExtendStage, + WorkflowMemory, + WorkflowReflect, + WorkflowStage, + parse_workflow, +) + +# Base STAGES-format pipeline-file for the compiler tests — three stages, +# verbatim from the design. +_BASE_STAGES = ( + "name: demo\n" + "description: Demo pipeline\n" + "---\n" + "brainstorm:\n" + " title: Brainstorm\n" + " prompt: Think\n" + "build:\n" + " title: Build\n" + " prompt: Make\n" + "review:\n" + " title: Review\n" + " prompt: Check\n" +) + +# Base PHASES-format pipeline-file — the list body derives ``depends_on`` by +# position, pinning that memory emission never disturbs the position rules. +_BASE_PHASES = ( + "name: demo\n" + "description: Demo pipeline\n" + "---\n" + "- name: brainstorm\n" + " title: Brainstorm\n" + " prompt: Think\n" + "- name: build\n" + " title: Build\n" + " prompt: Make\n" +) + + +def _compile( + tmp_path: Path, + pipeline_text: str, + workflow_text: str | None = None, +) -> tuple[PipelineDocument, FlowDocument, str]: + """Write the pipeline (and optional workflow), compile, return documents + text. + + Mirrors the ``_compile`` helper of ``test_compile_flow_timeout.py`` but also + returns the documents tuple so the memory tests can assert on the assembled + ``FlowDocument`` and its stages. + """ + pipeline_path = tmp_path / "pipeline.yml" + pipeline_path.write_text(pipeline_text) + flow_path = tmp_path / "flow.yml" + + workflow = None + + if workflow_text is not None: + workflow_path = tmp_path / "workflow.yml" + workflow_path.write_text(workflow_text) + workflow = parse_workflow(workflow_path) + + pipeline_doc, flow_doc = compile_flow(pipeline_path, flow_path, workflow=workflow) + + return pipeline_doc, flow_doc, flow_path.read_text() + + +class TestCompileFlowMemoryContract: + """Contract tests — the memory surface declared by the compiler CODEMANIFEST.""" + + def test_compile_flow_signature_unchanged_no_memory_parameter(self) -> None: + """``compile_flow`` keeps its five parameters — memory travels inside ``WorkflowDocument``.""" + parameters = list(inspect.signature(compile_flow).parameters) + + assert parameters == ["pipeline_path", "flow_path", "workflow", "root_dir", "project_name"] + + def test_memory_helpers_not_on_facade(self) -> None: + """``_memory_emission`` & co. are module-internal, not facade names.""" + from goga.pipeline.compiler import __all__ as facade_all + + assert "_memory_emission" not in facade_all + assert "_MemoryEmission" not in facade_all + assert "_assemble_memory_keys" not in facade_all + assert "_MEMORY_ROOT" not in facade_all + + def test_memory_helpers_exist_in_module(self) -> None: + """The step-4.9 helpers exist with the contract shape.""" + from goga.pipeline.compiler.compile_flow import _MEMORY_ROOT + + assert _MEMORY_ROOT == ".goga/memory" + assert callable(_memory_emission) + assert callable(_assemble_memory_keys) + assert [f.name for f in dataclasses.fields(_MemoryEmission)] == ["block", "keys_by_id"] + + def test_canonical_key_order_ends_with_memory_slots(self) -> None: + """``reflect`` and ``memory_use`` close the canonical order after ``script_timeout``.""" + assert _CANONICAL_KEY_ORDER[-6:] == [ + "script_before", + "script", + "script_after", + "script_timeout", + "reflect", + "memory_use", + ] + assert _CANONICAL_KEY_ORDER.index("reflect") == _CANONICAL_KEY_ORDER.index("script_timeout") + 1 + assert _CANONICAL_KEY_ORDER.index("memory_use") == _CANONICAL_KEY_ORDER.index("reflect") + 1 + + def test_canonical_fields_accepts_memory_fields(self) -> None: + """``_canonical_fields`` takes ``(body, stage_name, notes, memory_fields)`` — both optional.""" + parameters = list(inspect.signature(_canonical_fields).parameters) + + assert parameters == ["body", "stage_name", "notes", "memory_fields"] + assert inspect.signature(_canonical_fields).parameters["notes"].default is None + assert inspect.signature(_canonical_fields).parameters["memory_fields"].default is None + + +class TestCompileFlowMemoryEmission: + """Step 4.9 / 5 / 6 — block emission and stage-key assembly across the six cases.""" + + def test_compile_flow_no_block_with_reflect_instructions_emits_block(self, tmp_path: Path) -> None: + """Emission case 2 — instructions with no authored block emit it from the materialized defaults.""" + _pipeline_doc, flow_doc, text = _compile( + tmp_path, + _BASE_STAGES, + "stages:\n brainstorm:\n reflect:\n file: shared.md\n", + ) + + assert flow_doc.memory == FlowMemory( + path=".goga/memory", mode="r", memory_use=False, max_rules=25, commit=False + ) + assert "memory:" in text + assert "path: .goga/memory" in text + assert "mode: r" in text.split("stages:")[0] + assert "memory_use: false" in text.split("stages:")[0] + assert "max_rules: 25" in text + assert "commit: false" in text + assert " reflect:" in text + assert "file: shared.md" in text + assert flow_doc.stages[0].fields["reflect"] == {"file": "shared.md", "mode": "rw"} + assert flow_doc.stages[1].fields.get("reflect") is None + assert flow_doc.stages[2].fields.get("reflect") is None + + def test_compile_flow_reflect_authored_mode_carries_verbatim(self, tmp_path: Path) -> None: + """An authored reflect ``mode`` reaches the stage key verbatim (materialization is the fallback).""" + _pipeline_doc, flow_doc, text = _compile( + tmp_path, + _BASE_STAGES, + "stages:\n build:\n reflect:\n file: a.md\n mode: r\n", + ) + + assert flow_doc.stages[1].fields["reflect"] == {"file": "a.md", "mode": "r"} + assert "mode: r" in text + + def test_compile_flow_max_rules_boundary_reaches_block(self, tmp_path: Path) -> None: + """The inclusive ``max_rules`` lower boundary (1) reaches the emitted block verbatim.""" + _pipeline_doc, flow_doc, text = _compile( + tmp_path, + _BASE_STAGES, + "memory:\n max_rules: 1\nstages:\n build:\n reflect:\n file: a.md\n", + ) + + assert flow_doc.memory is not None + assert flow_doc.memory.max_rules == 1 + assert "max_rules: 1" in text + + def test_compile_flow_alignment_emits_block_and_marks_every_stage(self, tmp_path: Path) -> None: + """Emission case 4 — alignment marks participating stages and opts the rest out explicitly.""" + workflow_text = ( + "memory:\n" + " method: alignment\n" + " path: goga-development\n" + "stages:\n" + " brainstorm:\n" + " memory: true\n" + " build:\n" + " memory: true\n" + ) + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory == FlowMemory( + path=".goga/memory/goga-development", + mode="rw", + memory_use=False, + max_rules=25, + commit=False, + ) + assert flow_doc.stages[0].fields["memory_use"] is True + assert flow_doc.stages[1].fields["memory_use"] is True + assert flow_doc.stages[2].fields["memory_use"] is False + assert all("reflect" not in stage.fields for stage in flow_doc.stages) + assert "memory_use: false" in text + + def test_compile_flow_alignment_authored_mode_carries_verbatim(self, tmp_path: Path) -> None: + """An authored alignment ``mode`` reaches the block verbatim (materialization is the fallback).""" + workflow_text = "memory:\n method: alignment\n path: p\n mode: r\nstages:\n build:\n memory: true\n" + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is not None + assert flow_doc.memory.mode == "r" + assert flow_doc.memory.memory_use is False + assert "mode: r" in text + + def test_compile_flow_reflect_slot_after_script_timeout(self, tmp_path: Path) -> None: + """The stage ``reflect`` key occupies the canonical slot immediately after ``script_timeout``.""" + pipeline_text = ( + "name: demo\ndescription: Demo pipeline\n---\nbuild:\n title: Build\n script: make build\n timeout: 5m\n" + ) + _pipeline_doc, flow_doc, _text = _compile( + tmp_path, + pipeline_text, + "stages:\n build:\n reflect:\n file: shared.md\n", + ) + + fields = flow_doc.stages[0].fields + + assert list(fields).index("script_timeout") < list(fields).index("reflect") + + def test_compile_flow_reflect_uniform_across_loop_copies(self, tmp_path: Path) -> None: + """Every loop-expanded copy ``NAME-i`` carries the same reflect instruction as its base.""" + _pipeline_doc, flow_doc, _text = _compile( + tmp_path, + _BASE_STAGES, + "stages:\n brainstorm:\n loop: 3\n reflect:\n file: shared.md\n", + ) + + copies = [stage for stage in flow_doc.stages if stage.id.startswith("brainstorm")] + + assert len(copies) == 3 + for stage in copies: + assert stage.fields["reflect"] == {"file": "shared.md", "mode": "rw"} + + def test_compile_flow_alignment_uniform_across_loop_copies(self, tmp_path: Path) -> None: + """Every loop-expanded copy carries its base's ``memory_use`` — participants and opt-outs alike.""" + workflow_text = "memory:\n method: alignment\nstages:\n brainstorm:\n loop: 3\n memory: true\n" + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + copies = [stage for stage in flow_doc.stages if stage.id.startswith("brainstorm")] + bystanders = [stage for stage in flow_doc.stages if not stage.id.startswith("brainstorm")] + + assert flow_doc.memory is not None + assert len(copies) == 3 + for stage in copies: + assert stage.fields["memory_use"] is True + + for stage in bystanders: + assert stage.fields["memory_use"] is False + assert "memory_use: false" in text + + def test_compile_flow_phases_reflect_emits_block_and_stage_keys(self, tmp_path: Path) -> None: + """The PHASES list body emits the block and the stage keys identically — format-agnostic.""" + workflow_text = "stages:\n brainstorm:\n reflect:\n file: shared.md\n" + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_PHASES, workflow_text) + + assert flow_doc.memory == FlowMemory( + path=".goga/memory", mode="r", memory_use=False, max_rules=25, commit=False + ) + assert flow_doc.stages[0].fields["reflect"] == {"file": "shared.md", "mode": "rw"} + assert flow_doc.stages[0].depends_on is None + assert flow_doc.stages[1].fields.get("reflect") is None + assert flow_doc.stages[1].depends_on == ["brainstorm"] + assert " reflect:" in text + + def test_compile_flow_reflect_applies_to_extend_stage_by_name(self, tmp_path: Path) -> None: + """An embedded extend-stage participates through its ``stages``-block entry (by name).""" + workflow_text = ( + "stages:\n" + " extra:\n" + " reflect:\n" + " file: extra.md\n" + "extend:\n" + " extra:\n" + " after:\n" + " - build\n" + " title: Extra\n" + " prompt: extra work\n" + ) + _pipeline_doc, flow_doc, _text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + extra_stage = next(stage for stage in flow_doc.stages if stage.id == "extra") + + assert extra_stage.fields["reflect"] == {"file": "extra.md", "mode": "rw"} + assert flow_doc.memory is not None + others = [stage for stage in flow_doc.stages if stage.id != "extra"] + assert all("reflect" not in stage.fields for stage in others) + + def test_compile_flow_alignment_applies_to_extend_stage_by_name(self, tmp_path: Path) -> None: + """An embedded extend-stage participates under alignment through its ``stages`` entry.""" + workflow_text = ( + "memory:\n" + " method: alignment\n" + "stages:\n" + " extra:\n" + " memory: true\n" + "extend:\n" + " extra:\n" + " after:\n" + " - build\n" + " title: Extra\n" + " prompt: extra work\n" + ) + _pipeline_doc, flow_doc, _text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + extra_stage = next(stage for stage in flow_doc.stages if stage.id == "extra") + + assert flow_doc.memory is not None + assert extra_stage.fields["memory_use"] is True + others = [stage for stage in flow_doc.stages if stage.id != "extra"] + assert all(stage.fields["memory_use"] is False for stage in others) + + def test_compile_flow_block_without_instructions_is_silent_noop(self, tmp_path: Path) -> None: + """Emission case 3 — a configuration-only block emits nothing at all.""" + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, "memory:\n max_rules: 40\n") + + assert flow_doc.memory is None + assert "memory:" not in text + assert all("reflect" not in stage.fields and "memory_use" not in stage.fields for stage in flow_doc.stages) + + def test_compile_flow_alignment_all_false_is_silent_noop(self, tmp_path: Path) -> None: + """Emission case 5 — alignment with no true instruction is a silent no-op.""" + workflow_text = "memory:\n method: alignment\nstages:\n build:\n memory: false\n" + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is None + assert "memory:" not in text + assert all("memory_use" not in stage.fields for stage in flow_doc.stages) + + def test_compile_flow_skip_of_only_participating_stage_emits_no_block(self, tmp_path: Path) -> None: + """A stage removed by skip never counts — its instructions die with it (design scenario 3).""" + workflow_text = "stages:\n brainstorm:\n reflect:\n file: a.md\n skip: true\n" + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is None + assert "memory:" not in text + assert all("reflect" not in stage.fields and "memory_use" not in stage.fields for stage in flow_doc.stages) + + def test_compile_flow_alignment_skip_of_only_participating_stage_emits_no_block(self, tmp_path: Path) -> None: + """Under alignment a skipped participant dies with its instruction — no block, no keys.""" + workflow_text = "memory:\n method: alignment\nstages:\n build:\n memory: true\n skip: true\n" + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is None + assert "memory:" not in text + assert all("memory_use" not in stage.fields for stage in flow_doc.stages) + + def test_compile_flow_reflect_block_carries_mode_r_and_memory_use_false(self, tmp_path: Path) -> None: + """Emission case 6 — the reflect-method block carries mode: r and memory_use: false.""" + workflow_text = ( + "memory:\n max_rules: 9\n commit: true\nstages:\n brainstorm:\n reflect:\n file: shared.md\n" + ) + _pipeline_doc, flow_doc, text = _compile(tmp_path, _BASE_STAGES, workflow_text) + + assert flow_doc.memory is not None + assert flow_doc.memory.mode == "r" + assert flow_doc.memory.memory_use is False + + block_text = text.split("stages:")[0].split("memory:")[1] + + assert "mode: r" in block_text + assert "memory_use: false" in block_text + assert "commit: true" in text + + +class TestCompileFlowMemoryAuthoringProhibition: + """Step 5 — the authoring-forbidden memory keys in any stage body.""" + + @pytest.mark.parametrize( + ("stage_body", "message"), + [ + pytest.param( + " reflect:\n file: a.md\n", + "reflect key is forbidden in stage body; use reflect in workflow.stages", + id="reflect", + ), + pytest.param( + " memory_use: false\n", + "memory_use key is forbidden in stage body; use memory in workflow.stages", + id="memory-use", + ), + ], + ) + def test_compile_flow_rejects_authoring_memory_keys_in_stage_body( + self, + tmp_path: Path, + stage_body: str, + message: str, + ) -> None: + """A pipeline-file stage body cannot author the output-side memory keys.""" + pipeline_path = tmp_path / "pipeline.yml" + pipeline_path.write_text( + "name: demo\ndescription: Demo pipeline\n---\nbuild:\n title: Build\n prompt: Make\n" + stage_body, + ) + + with pytest.raises(StructuralError, match=re.escape(message)): + compile_flow(pipeline_path, tmp_path / "flow.yml") + + def test_compile_flow_rejects_authoring_reflect_in_extend_body(self, tmp_path: Path) -> None: + """An embedded extend-stage body hits the same prohibition (same ``_canonical_fields`` pass).""" + pipeline_path = tmp_path / "pipeline.yml" + pipeline_path.write_text(_BASE_STAGES) + workflow = WorkflowDocument( + extend={ + "extra": WorkflowExtendStage( + after=["build"], + body={"title": "Extra", "reflect": {"file": "a.md"}}, + ), + }, + ) + + with pytest.raises( + StructuralError, + match=re.escape("reflect key is forbidden in stage body; use reflect in workflow.stages"), + ): + compile_flow(pipeline_path, tmp_path / "flow.yml", workflow=workflow) + + +class TestCompileFlowMemoryPlumbing: + """Direct unit tests of the private step-4.9 helpers (the ``notes_by_id`` precedent).""" + + def test_effective_overrides_merged_branch_passes_reflect_and_memory(self) -> None: + """A stage named in both ``extend`` and ``stages`` keeps its participation instructions.""" + workflow = WorkflowDocument( + stages={ + "x": WorkflowStage(reflect=WorkflowReflect(file="a.md"), memory=None), + "y": WorkflowStage(memory=True), + }, + extend={ + "x": WorkflowExtendStage(after=["y"], body={}), + "z": WorkflowExtendStage(after=["x"], body={}), + }, + ) + + effective = _effective_overrides(workflow) + + assert effective["x"].reflect == WorkflowReflect(file="a.md", mode="rw") + assert effective["y"].memory is True + assert effective["z"].reflect is None + assert effective["z"].memory is None + + def test_memory_emission_none_workflow_yields_no_block(self) -> None: + """A ``None`` workflow never participates — no block, no stage keys.""" + emission = _memory_emission(None, {}, {}) + + assert emission.block is None + assert emission.keys_by_id == {} + + def test_memory_emission_default_config_supplies_block_values(self) -> None: + """Reflect instructions with no authored block source the values from ``WorkflowMemory()``.""" + workflow = WorkflowDocument(stages={"build": WorkflowStage(reflect=WorkflowReflect(file="a.md"))}) + effective = _effective_overrides(workflow) + + emission = _memory_emission(workflow, effective, {"build": ["build"]}) + + assert emission.block == FlowMemory(path=".goga/memory", mode="r", memory_use=False, max_rules=25, commit=False) + assert emission.keys_by_id == {"build": {"reflect": {"file": "a.md", "mode": "rw"}}} + + def test_memory_emission_alignment_marks_every_final_id(self) -> None: + """Alignment emits an explicit opt-out on every non-participating final id.""" + alignment = WorkflowDocument( + memory=WorkflowMemory(method="alignment"), + stages={"build": WorkflowStage(memory=True), "review": WorkflowStage()}, + ) + + emission = _memory_emission( + alignment, + _effective_overrides(alignment), + {"build": ["build"], "review": ["review"]}, + ) + + assert emission.keys_by_id == {"build": {"memory_use": True}, "review": {"memory_use": False}} + + def test_memory_emission_no_participation_yields_empty_emission(self) -> None: + """A configuration over a working body with no participants is a silent no-op (cases 3 and 5).""" + reflect_config = WorkflowDocument( + memory=WorkflowMemory(max_rules=40), + stages={"build": WorkflowStage(), "review": WorkflowStage()}, + ) + alignment_config = WorkflowDocument( + memory=WorkflowMemory(method="alignment"), + stages={"build": WorkflowStage(), "review": WorkflowStage()}, + ) + + for document in (reflect_config, alignment_config): + emission = _memory_emission( + document, + _effective_overrides(document), + {"build": ["build"], "review": ["review"]}, + ) + + assert emission.block is None + assert emission.keys_by_id == {} + + def test_assemble_memory_keys_noop_on_none_and_empty(self) -> None: + """``None`` and an empty map assemble nothing — a non-participating stage carries no key.""" + source: dict[str, object] = {"prompt": "p"} + + _assemble_memory_keys(source, None) + _assemble_memory_keys(source, {}) + + assert source == {"prompt": "p"} + + def test_assemble_memory_keys_assigns_fresh_values(self) -> None: + """A non-empty map updates the source — the canonical loop then slots the keys.""" + source: dict[str, object] = {"prompt": "p"} + + _assemble_memory_keys(source, {"reflect": {"file": "a.md", "mode": "rw"}}) + + assert source == {"prompt": "p", "reflect": {"file": "a.md", "mode": "rw"}} diff --git a/tests/pipeline/compiler/test_compile_flow_notes.py b/tests/pipeline/compiler/test_compile_flow_notes.py new file mode 100644 index 00000000..59dc425b --- /dev/null +++ b/tests/pipeline/compiler/test_compile_flow_notes.py @@ -0,0 +1,400 @@ +"""Contract and logic tests for the ``compile_flow`` workflow notes instruction. + +Covers the notes half of step 5 (``_canonical_fields``) plus the +reconstruction plumbing that feeds it: when the workflow stages block carries +notes for a stage (a map of note name → prompt text on ``WorkflowStage``), +the compiler assembles the stage's ``buttons`` field — + +- in the canonical slot immediately after ``description`` (and before + ``agents``), for BOTH body formats (STAGES and PHASES); +- verbatim — keys and values unchanged, one deep copy per ``FlowStage`` so + the fields never alias ``WorkflowStage.notes``; +- uniformly across every loop-expanded copy (``NAME-1``..``NAME-N`` all carry + the same buttons — resolution by BASE name); +- for embedded extend-stages by name (the stages block is the single + authoring source for extend-stages too); +- never for a stage without a non-empty notes instruction (omitempty — + ``{}`` is normalized to ``None`` upstream, so no ``buttons`` key appears + and notes-free pipelines compile byte-identically); +- never for a skipped stage (skip removal runs before the notes resolution); +- never through a stage-body ``buttons`` key — an authoring buttons key in a + pipeline-file stage OR an embedded extend body raises + ``StructuralError("buttons key is forbidden in stage body; use notes in + workflow.stages")``; +- output-side only — the ``PipelineDocument`` mirror and the workflow's own + notes map stay untouched. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from goga.pipeline.compiler import StructuralError, compile_flow +from goga.pipeline.workflow import ( + WorkflowDocument, + WorkflowExtendStage, + WorkflowStage, + parse_workflow, +) + +_HEADER = "name: Feature\ndescription: Feature implementation\n---\n" + + +def _write(tmp_path: Path, body: str) -> Path: + """Write a STAGES pipeline (or a PHASES list body) to a temp file and return its path.""" + pipeline_path = tmp_path / "pipeline.yml" + pipeline_path.write_text(_HEADER + body) + + return pipeline_path + + +def _compile(tmp_path: Path, pipeline_text: str, workflow_text: str | None = None) -> str: + """Write the pipeline (and optional workflow), compile, return the flow-file text.""" + pipeline_path = tmp_path / "pipeline.yml" + pipeline_path.write_text(pipeline_text) + flow_path = tmp_path / "flow.yml" + + workflow = None + + if workflow_text is not None: + workflow_path = tmp_path / "workflow.yml" + workflow_path.write_text(workflow_text) + workflow = parse_workflow(workflow_path) + + compile_flow(pipeline_path, flow_path, workflow=workflow) + + return flow_path.read_text() + + +class TestNotesButtonsAssembly: + """Step 5 — notes → buttons assembly across body sources and formats.""" + + def test_notes_assemble_buttons_after_description(self, tmp_path: Path) -> None: + """The assembled ``buttons`` occupies the canonical slot right after ``description``. + + The workflow ``prompt: override`` populates the stage's ``description`` + so the byte-level neighbor assertion has a ``description`` key to sit + against; ``agents`` (the next canonical key after ``buttons``) is + injected by default. The canonical slot is the contract's externally + visible guarantee — this pins the exact neighbors. + """ + flow_text = _compile( + tmp_path, + _HEADER + "s:\n title: S\n prompt: do work\n", + "stages:\n s:\n prompt: override\n notes:\n fix: Fix it\n", + ) + + keys = list(yaml.safe_load(flow_text)["stages"][0]) + + assert keys.index("description") + 1 == keys.index("buttons") + assert keys.index("buttons") < keys.index("agents") + assert "buttons:" in flow_text + assert " fix: Fix it" in flow_text + + def test_notes_assemble_buttons_phases_format(self, tmp_path: Path) -> None: + """The PHASES list body assembles buttons identically — the directive is format-agnostic. + + Pins the PHASES call site of ``_canonical_fields`` with the ``notes=`` + argument: a regression dropping ``notes`` only in the PHASES branch + silently loses buttons for phases pipelines while every STAGES test + stays green. + """ + flow_text = _compile( + tmp_path, + _HEADER + "- name: build\n title: Build\n prompt: do work\n", + "stages:\n build:\n notes:\n fix: Fix it\n", + ) + + stage = yaml.safe_load(flow_text)["stages"][0] + + assert stage["buttons"] == {"fix": "Fix it"} + assert "fix: Fix it" in flow_text + assert "depends_on" not in stage + + def test_loop_expanded_copies_carry_same_buttons(self, tmp_path: Path) -> None: + """Every loop-expanded copy ``NAME-i`` carries the same buttons map. + + Resolution is by base name — a copy's final id (``build-i``) never + appears in ``workflow.stages``, so a naive ``effective.get(step.name)`` + lookup would silently drop the buttons for every copy. The in-memory + variant also pins the deep-copy discipline: the three maps are equal + content but independent objects (one deep copy per ``FlowStage``). + """ + flow_text = _compile( + tmp_path, + _HEADER + "build:\n title: Build\n prompt: do work\n", + "stages:\n build:\n loop: 3\n notes:\n retry: Retry now\n", + ) + + stages = yaml.safe_load(flow_text)["stages"] + + assert [(stage["id"], stage.get("buttons")) for stage in stages] == [ + ("build-1", {"retry": "Retry now"}), + ("build-2", {"retry": "Retry now"}), + ("build-3", {"retry": "Retry now"}), + ] + + _pipeline_doc, flow_doc = compile_flow( + _write(tmp_path, "build:\n title: Build\n prompt: do work\n"), + tmp_path / "flow2.yml", + workflow=WorkflowDocument( + stages={"build": WorkflowStage(loop=3, notes={"retry": "Retry now"})}, + ), + ) + buttons = [stage.fields["buttons"] for stage in flow_doc.stages] + + assert buttons[0] == buttons[1] == buttons[2] == {"retry": "Retry now"} + assert buttons[0] is not buttons[1] + assert buttons[1] is not buttons[2] + assert buttons[0] is not buttons[2] + + def test_multiline_note_value_compiles_block_literal(self, tmp_path: Path) -> None: + """A multi-line note text compiles to a block-literal value through the full path. + + Exercising the whole chain (parse → effective-notes resolution → the + deep-copy hops → the serializer's block-literal wrapping) rather than + only a directly constructed ``FlowDocument``: the authored multi-line + prompt text must survive the compile unchanged and round-trip through + ``yaml.safe_load``. + """ + flow_text = _compile( + tmp_path, + _HEADER + "s:\n title: S\n prompt: do work\n", + "stages:\n s:\n notes:\n fix: |-\n Line1\n Line2\n", + ) + + stage = yaml.safe_load(flow_text)["stages"][0] + + assert stage["buttons"] == {"fix": "Line1\nLine2"} + assert " fix: |" in flow_text + assert " Line1" in flow_text + assert " Line2" in flow_text + + def test_notes_apply_to_extend_stage_by_name(self, tmp_path: Path) -> None: + """An extend-stage receives its buttons through the stages block by name. + + The single authoring source: ``stages.<new-stage-name>.notes`` — there + is no separate authoring inside an extend-entry (and ``notes`` there + is a parse-time structural error). + """ + flow_text = _compile( + tmp_path, + _HEADER + "a:\n title: A\n prompt: do a\n", + "stages:\n" + " extra:\n" + " notes:\n" + " fix: Fix extra\n" + "extend:\n" + " extra:\n" + " after: [a]\n" + " title: Extra\n" + " prompt: extra work\n", + ) + + stages = yaml.safe_load(flow_text)["stages"] + extra = next(stage for stage in stages if stage["id"] == "extra") + first = next(stage for stage in stages if stage["id"] == "a") + + assert extra["buttons"] == {"fix": "Fix extra"} + assert "buttons" not in first + + +class TestEffectiveNotesResolution: + """Step 4.5 — the effective override map carries the stages-block notes.""" + + def test_effective_overrides_merged_branch_passes_notes(self) -> None: + """The merged overlay branch passes ``notes=stg.notes`` explicitly. + + ``x`` is extend-seeded (inline defaults) AND overridden by a + stages-block entry carrying notes — the merged branch must carry the + notes. ``z`` is extend-only (no stages entry), so its notes can only + be the constructor default ``None`` — pinning that an extend-only + name keeps the seed untouched. Omitting the kwarg is the silent-drop + regression the contract calls out. + """ + from goga.pipeline.compiler.compile_flow import _effective_overrides + + workflow = WorkflowDocument( + stages={"x": WorkflowStage(notes={"a": "1"})}, + extend={ + "x": WorkflowExtendStage(after=["y"], body={}), + "z": WorkflowExtendStage(after=["x"], body={}), + }, + ) + + effective = _effective_overrides(workflow) + + assert effective["x"].notes == {"a": "1"} + assert effective["z"].notes is None + + +class TestNotesProhibitions: + """The single-authoring-source rule — authoring ``buttons`` keys are rejected.""" + + def test_authoring_buttons_in_pipeline_body_rejected(self, tmp_path: Path) -> None: + """An authoring ``buttons`` key in a pipeline-file stage body raises — no workflow needed. + + The prohibition is body-side: it fires with no workflow at all, + proving buttons are authored ONLY via the workflow stages-block notes + instruction. + """ + with pytest.raises(StructuralError) as excinfo: + _compile(tmp_path, _HEADER + "s:\n title: S\n buttons:\n fix: x\n") + + assert str(excinfo.value) == "buttons key is forbidden in stage body; use notes in workflow.stages" + + def test_authoring_buttons_in_extend_body_rejected(self, tmp_path: Path) -> None: + """An authoring ``buttons`` key in an embedded extend body raises the same error. + + Extend bodies flow through the same ``_canonical_fields`` pass, so the + prohibition covers "pipeline-file stage OR embedded extend-stage". + """ + with pytest.raises(StructuralError) as excinfo: + _compile( + tmp_path, + _HEADER + "a:\n title: A\n prompt: do a\n", + "extend:\n" + " extra:\n" + " after: [a]\n" + " title: Extra\n" + " prompt: extra work\n" + " buttons:\n" + " fix: x\n", + ) + + assert str(excinfo.value) == "buttons key is forbidden in stage body; use notes in workflow.stages" + + def test_notes_on_unknown_stage_name_raises_existing_error(self, tmp_path: Path) -> None: + """A notes-bearing entry for an unknown name hits the existing strict name validation. + + A notes instruction must not weaken the pre-existing 4pre check — the + error message is the same as for any other unknown stage name. + """ + with pytest.raises(StructuralError) as excinfo: + _compile( + tmp_path, + _HEADER + "a:\n title: A\n prompt: do a\n", + "stages:\n ghost:\n notes:\n fix: x\n", + ) + + assert str(excinfo.value) == "unknown stage name in workflow.stages: ghost" + + def test_body_notes_key_is_not_the_instruction(self, tmp_path: Path) -> None: + """A ``notes`` key in a stage body passes through as an ordinary unknown key. + + Only the authoring ``buttons`` key is prohibited; ``notes`` in a body + is NOT the workflow instruction (that lives in ``workflow.stages`` + alone), so it is neither consumed nor rejected — it lands in the + output verbatim like any unknown key and never becomes buttons. + """ + flow_text = _compile(tmp_path, _HEADER + "s:\n title: S\n notes:\n fix: x\n") + + stage = yaml.safe_load(flow_text)["stages"][0] + + assert stage["notes"] == {"fix": "x"} + assert "buttons" not in stage + + +class TestNotesEdges: + """Omission edges — empty notes, skip precedence, no-mutation, byte-identity.""" + + def test_empty_notes_compile_without_buttons_key(self, tmp_path: Path) -> None: + """The empty-notes workflow (``notes: {}`` → ``None`` upstream) assembles no buttons. + + The omitempty presence rule: ``parse_workflow`` normalizes ``{}`` to + ``None``, so the compiler never sees an empty-map instruction and the + word ``buttons`` never appears anywhere in the output. + """ + flow_text = _compile( + tmp_path, + _HEADER + "s:\n title: S\n prompt: do work\n", + "stages:\n s:\n notes: {}\n", + ) + + assert "buttons" not in yaml.safe_load(flow_text)["stages"][0] + assert "buttons" not in flow_text + + def test_notes_on_skipped_stage_not_applied(self, tmp_path: Path) -> None: + """A skip+notes entry never leaks buttons into the survivors. + + Skip removal (4skip) runs BEFORE the effective-notes resolution and the + assembly, so the skipped stage's notes are unreachable by design; the + dependent stage ``t`` is reconnected past ``s``. + """ + flow_text = _compile( + tmp_path, + _HEADER + + "s:\n" + + " title: S\n" + + " prompt: do s\n" + + "t:\n" + + " title: T\n" + + " prompt: do t\n" + + " depends_on: [s]\n", + "stages:\n s:\n skip: true\n notes:\n fix: x\n", + ) + + stages = yaml.safe_load(flow_text)["stages"] + + assert [stage["id"] for stage in stages] == ["t"] + assert "buttons" not in flow_text + + def test_workflow_notes_do_not_mutate_pipeline_document(self, tmp_path: Path) -> None: + """Output-side only — neither the pipeline mirror nor the workflow's map is touched. + + The notes instruction travels as a function argument into + ``FlowStage.fields`` (a deep copy): the ``PipelineDocument`` body never + carries a ``buttons``/``notes`` key, the workflow's own dict keeps its + content and identity, and the assembled map never aliases it. + """ + workflow = WorkflowDocument(stages={"s": WorkflowStage(notes={"fix": "F"})}) + notes_object = workflow.stages["s"].notes + + pipeline_doc, flow_doc = compile_flow( + _write(tmp_path, "s:\n title: S\n prompt: do work\n"), + tmp_path / "flow.yml", + workflow=workflow, + ) + + for step in pipeline_doc.body.steps: + assert "buttons" not in step.body + assert "notes" not in step.body + + assert workflow.stages["s"].notes == {"fix": "F"} + assert workflow.stages["s"].notes is notes_object + assert flow_doc.stages[0].fields["buttons"] == {"fix": "F"} + assert flow_doc.stages[0].fields["buttons"] is not workflow.stages["s"].notes + + def test_pipeline_without_notes_compiles_byte_identical(self, tmp_path: Path) -> None: + """A directive-rich pipeline compiles byte-identically with a notes-free NO-OP workflow. + + The regression gate for the canonical-order extension: an empty-entry + workflow (``stages: {build: {}}`` — a real stage name, all fields + defaulted, a no-op override) changes nothing, so the workflow-less and + NO-OP-workflow flow-files are equal byte-for-byte and neither carries + a ``buttons`` key. + """ + pipeline_text = ( + _HEADER + + "build:\n" + + " title: Build\n" + + " communication: true\n" + + " before_script: echo prep\n" + + " script: make all\n" + + " after_script: echo done\n" + + " timeout: 30m\n" + + "check:\n" + + " title: Check\n" + + " prompt: verify\n" + + " roles: [planner, reviewer]\n" + + " skills: [goga-review]\n" + + " trigger: manual\n" + ) + + text_a = _compile(tmp_path, pipeline_text) + text_b = _compile(tmp_path, pipeline_text, "stages:\n build: {}\n") + + assert text_a == text_b + assert "buttons" not in text_a diff --git a/tests/pipeline/compiler/test_flow_document_contract.py b/tests/pipeline/compiler/test_flow_document_contract.py index dd48f3af..d14cd029 100644 --- a/tests/pipeline/compiler/test_flow_document_contract.py +++ b/tests/pipeline/compiler/test_flow_document_contract.py @@ -1,4 +1,4 @@ -"""Contract tests for the ``FlowDocument.prompt`` first-slot extension. +"""Contract tests for the ``FlowDocument`` slot extensions. Covers the Task 5 data-model extension: ``FlowDocument`` gains an optional ``prompt: str | None`` field as its FIRST slot (default ``None``), emitted as @@ -6,13 +6,19 @@ entirely when ``None``. Because the dataclass is ``kw_only=True``, the new first-slot field with a default does not break existing keyword construction sites. + +Also covers the memory-slot extension: ``FlowDocument`` gains an optional +``memory: FlowMemory | None`` field between ``description`` and ``stages`` +(default ``None``), emitted between the two by ``serialize_flow`` when not +``None`` and omitted entirely when ``None`` — the byte-identity default for +memory-free workflows. """ from __future__ import annotations from dataclasses import fields as dataclass_fields -from goga.pipeline.compiler import FlowDocument, FlowStage +from goga.pipeline.compiler import FlowDocument, FlowMemory, FlowStage class TestFlowDocumentPromptContract: @@ -61,3 +67,53 @@ def test_existing_construction_sites_without_prompt_still_work(self) -> None: assert doc.description == "Feature implementation" assert doc.stages == [] assert doc.prompt is None + + +class TestFlowDocumentMemoryContract: + """Contract tests — the ``memory`` slot declared by the CODEMANIFEST.""" + + def test_flow_document_has_memory_field(self) -> None: + """``FlowDocument`` must declare a ``memory`` field.""" + names = [f.name for f in dataclass_fields(FlowDocument)] + + assert "memory" in names + + def test_flow_document_field_order_with_memory_slot(self) -> None: + """The fixed field order is prompt, root_dir, name, description, memory, stages. + + The order matches the canonical emission order in ``serialize_flow`` — + the memory block sits strictly between ``description`` and ``stages``. + """ + ordered = [f.name for f in dataclass_fields(FlowDocument)] + + assert ordered == ["prompt", "root_dir", "name", "description", "memory", "stages"] + assert ordered[3] == "description" + assert ordered[4] == "memory" + assert ordered[5] == "stages" + + def test_flow_document_memory_type_is_optional_flow_memory(self) -> None: + """``memory`` must be typed ``FlowMemory | None``.""" + memory_field = next(f for f in dataclass_fields(FlowDocument) if f.name == "memory") + + assert memory_field.type == "FlowMemory | None" + + def test_flow_document_memory_defaults_to_none(self) -> None: + """A ``FlowDocument`` built without ``memory`` has ``memory=None``. + + The default is the byte-identity pin — a document without memory + serializes without the block entirely. + """ + doc = FlowDocument(name="N", description="D", stages=[]) + + assert doc.memory is None + + def test_flow_document_round_trips_with_memory(self) -> None: + """An explicit ``FlowMemory`` round-trips through construction verbatim.""" + stage = FlowStage(id="a", name="A", depends_on=None, fields={}) + block = FlowMemory(path=".goga/memory/x", mode="rw", memory_use=True, max_rules=25, commit=False) + doc = FlowDocument(name="N", description="D", memory=block, stages=[stage]) + + assert doc.memory is block + assert doc.name == "N" + assert doc.description == "D" + assert doc.stages == [stage] diff --git a/tests/pipeline/compiler/test_flow_memory_contract.py b/tests/pipeline/compiler/test_flow_memory_contract.py new file mode 100644 index 00000000..0c549744 --- /dev/null +++ b/tests/pipeline/compiler/test_flow_memory_contract.py @@ -0,0 +1,82 @@ +"""Contract tests for the ``FlowMemory`` dataclass. + +Verifies the public API declared by the compiler-cell CODEMANIFEST: +importability from the facade (including the ``__all__`` obligation), the five +declared properties, the fixed field order (the emission order of the block +keys), kw_only construction, and the no-default pins of the three required +fields (``path`` / ``max_rules`` / ``commit`` — only ``compile_flow`` builds +this type). These tests pin the contract surface — behavior lives in the +logic test module. +""" + +from __future__ import annotations + +from dataclasses import fields + +import pytest +from goga.pipeline.compiler import FlowMemory + + +class TestFlowMemoryContract: + """Contract tests — the public API declared by the compiler-cell CODEMANIFEST.""" + + def test_flow_memory_importable_from_facade(self) -> None: + """FlowMemory is importable from the facade and listed in ``__all__``.""" + import goga.pipeline.compiler as facade + + assert facade.FlowMemory is FlowMemory + assert "FlowMemory" in facade.__all__ + + def test_flow_memory_has_path_property(self) -> None: + """FlowMemory exposes a ``path`` property.""" + assert hasattr(FlowMemory(path=".goga/memory", max_rules=25, commit=False), "path") + assert FlowMemory(path=".goga/memory/x", max_rules=25, commit=False).path == ".goga/memory/x" + + def test_flow_memory_has_mode_property(self) -> None: + """FlowMemory exposes a ``mode`` property defaulting to None.""" + assert hasattr(FlowMemory(path=".goga/memory", max_rules=25, commit=False), "mode") + block = FlowMemory(path=".goga/memory", max_rules=25, commit=False) + assert block.mode is None + assert FlowMemory(path=".goga/memory", mode="rw", max_rules=25, commit=False).mode == "rw" + + def test_flow_memory_has_memory_use_property(self) -> None: + """FlowMemory exposes a ``memory_use`` property defaulting to None.""" + assert hasattr(FlowMemory(path=".goga/memory", max_rules=25, commit=False), "memory_use") + block = FlowMemory(path=".goga/memory", max_rules=25, commit=False) + assert block.memory_use is None + assert FlowMemory(path=".goga/memory", memory_use=True, max_rules=25, commit=False).memory_use is True + + def test_flow_memory_has_max_rules_property(self) -> None: + """FlowMemory exposes a ``max_rules`` property.""" + assert hasattr(FlowMemory(path=".goga/memory", max_rules=25, commit=False), "max_rules") + assert FlowMemory(path=".goga/memory", max_rules=40, commit=False).max_rules == 40 + + def test_flow_memory_has_commit_property(self) -> None: + """FlowMemory exposes a ``commit`` property.""" + assert hasattr(FlowMemory(path=".goga/memory", max_rules=25, commit=False), "commit") + assert FlowMemory(path=".goga/memory", max_rules=25, commit=True).commit is True + + def test_flow_memory_field_order_fixed(self) -> None: + """Field order is fixed: path, mode, memory_use, max_rules, commit.""" + names = [field.name for field in fields(FlowMemory)] + + assert names == ["path", "mode", "memory_use", "max_rules", "commit"] + + def test_flow_memory_constructible_kw_only(self) -> None: + """FlowMemory is keyword-only — positional construction raises TypeError.""" + with pytest.raises(TypeError): + FlowMemory(".goga/memory", None, None, 25, False) # type: ignore[misc] + + def test_flow_memory_required_fields_have_no_defaults(self) -> None: + """Only ``mode`` / ``memory_use`` default — every other field is required. + + ``path`` / ``max_rules`` / ``commit`` carry NO defaults: a block is + always complete (``compile_flow`` is the single construction site, and + a silently-defaulted field would fabricate emission values). + """ + with pytest.raises(TypeError): + FlowMemory() # type: ignore[call-arg] + with pytest.raises(TypeError): + FlowMemory(path=".goga/memory") # type: ignore[call-arg] + with pytest.raises(TypeError): + FlowMemory(path=".goga/memory", max_rules=25) # type: ignore[call-arg] diff --git a/tests/pipeline/compiler/test_flow_memory_logic.py b/tests/pipeline/compiler/test_flow_memory_logic.py new file mode 100644 index 00000000..46807d10 --- /dev/null +++ b/tests/pipeline/compiler/test_flow_memory_logic.py @@ -0,0 +1,69 @@ +"""Logic tests for the ``FlowMemory`` dataclass. + +Covers construction behavior beyond the contract surface: the emission-order +pin of the field list and the two method shapes — the reflect-method block +(``mode: r`` / ``memory_use: False``) versus the alignment-method block +(``mode`` the materialized authored value, ``memory_use`` ``False``). The +``None`` fields are the omission signal the serializer drops — a block must +never conflate an unset field with an authored value. +""" + +from __future__ import annotations + +from dataclasses import fields + +from goga.pipeline.compiler import FlowMemory + + +class TestFlowMemoryLogic: + """Logic tests — construction behavior of the ``FlowMemory`` dataclass.""" + + def test_flow_memory_field_order_is_emission_order(self) -> None: + """Field order equals the emission order of the block keys. + + ``serialize_flow`` reads the block in this order (path, mode, + memory_use, max_rules, commit) — a reorder of the dataclass fields + would silently shift the compiled output. + """ + names = [field.name for field in fields(FlowMemory)] + + assert names == ["path", "mode", "memory_use", "max_rules", "commit"] + + def test_flow_memory_none_fields_distinct_from_values(self) -> None: + """The reflect shape carries ``mode: r``/``memory_use: False``; alignment the authored mode. + + A reflect-method block is the shape the compiler builds from a bare + reflect configuration — the fixed ``mode: r`` and the global opt-out + ``memory_use: False`` (read-only project memory, no global + participation). The alignment-method block carries the materialized + authored mode (``rw`` by default) and the same global + ``memory_use: False`` — participation is per-stage opt-in. The model's + ``None`` defaults remain the omission signal for the serializer — a + block must never conflate an unset field with an authored value. + """ + reflect_block = FlowMemory(path=".goga/memory", mode="r", memory_use=False, max_rules=25, commit=False) + + assert reflect_block.mode == "r" + assert reflect_block.memory_use is False + assert reflect_block.path == ".goga/memory" + assert reflect_block.max_rules == 25 + assert reflect_block.commit is False + + alignment_block = FlowMemory( + path=".goga/memory/goga-development", + mode="rw", + memory_use=False, + max_rules=25, + commit=False, + ) + + assert alignment_block.mode == "rw" + assert alignment_block.memory_use is False + assert alignment_block.path == ".goga/memory/goga-development" + + def test_flow_memory_equality_of_identical_constructions(self) -> None: + """Two blocks with identical fields compare equal (dataclass equality).""" + left = FlowMemory(path=".goga/memory", max_rules=25, commit=False) + right = FlowMemory(path=".goga/memory", mode=None, memory_use=None, max_rules=25, commit=False) + + assert left == right diff --git a/tests/pipeline/compiler/test_integration.py b/tests/pipeline/compiler/test_integration.py index d5722762..97868df5 100644 --- a/tests/pipeline/compiler/test_integration.py +++ b/tests/pipeline/compiler/test_integration.py @@ -29,6 +29,7 @@ "command", "prompt", "description", + "buttons", "agents", "supervisor", "supervisor_prompt", diff --git a/tests/pipeline/compiler/test_serialize_flow_buttons_slot.py b/tests/pipeline/compiler/test_serialize_flow_buttons_slot.py new file mode 100644 index 00000000..5f8b3a78 --- /dev/null +++ b/tests/pipeline/compiler/test_serialize_flow_buttons_slot.py @@ -0,0 +1,100 @@ +"""Logic tests for the per-stage ``buttons`` slot + ``serialize_flow`` emission. + +Covers the Task 4 serializer extension: the per-stage ``buttons`` mapping (a map +of note name → prompt text assembled by ``compile_flow`` from the workflow +``notes`` instruction) serializes as a regular block-style mapping — +``default_flow_style=False`` already renders it block-style, so the branch adds +no new dump parameters. Single-line values serialize as plain scalars (quoted as +needed by the ``SafeDumper``), multi-line values are wrapped in the existing +``_BlockLiteralScript`` marker so they serialize in block-literal scalar style; +insertion order is preserved verbatim (never sorted). +""" + +from __future__ import annotations + +import yaml +from goga.pipeline.compiler import FlowDocument, FlowStage, serialize_flow + + +def _doc_with_fields(fields: dict[str, object]) -> FlowDocument: + """Build a minimal ``FlowDocument`` carrying one stage with the given ``fields``.""" + return FlowDocument( + name="N", + description="D", + stages=[FlowStage(id="a", name="A", depends_on=None, fields=fields)], + ) + + +class TestSerializeFlowButtonsSlot: + """Behavioral tests for the per-stage ``buttons`` emission rules.""" + + def test_serialize_flow_importable(self) -> None: + """``serialize_flow`` is exported from the compiler facade.""" + from goga.pipeline.compiler import serialize_flow as imported + + assert callable(imported) + + def test_serialize_single_line_button_value_plain(self) -> None: + """A single-line button value serializes as a plain scalar — unquoted.""" + doc = _doc_with_fields({"buttons": {"fix": "Fix it", "probe": "Line1\nLine2"}}) + + text = serialize_flow(doc) + + assert " fix: Fix it" in text + # Not block-literal, not quoted. + assert " fix: |" not in text + assert " fix: 'Fix it'" not in text + + def test_serialize_multiline_button_value_block_literal(self) -> None: + """A multi-line button value serializes in block-literal scalar style.""" + doc = _doc_with_fields({"buttons": {"fix": "Fix it", "probe": "Line1\nLine2"}}) + + text = serialize_flow(doc) + + assert " probe: |" in text + assert " probe: 'Line1" not in text + # Each line indented under the block-literal header. + assert " Line1" in text + assert " Line2" in text + + def test_serialize_buttons_map_block_style(self) -> None: + """The buttons mapping serializes block-style — not as a flow-style map.""" + doc = _doc_with_fields({"buttons": {"fix": "Fix it", "probe": "Line1\nLine2"}}) + + text = serialize_flow(doc) + + assert "buttons:" in text + assert "buttons: {" not in text + + def test_serialize_button_value_quoted_as_needed(self) -> None: + """YAML-ambiguous single-line values are quoted so afm reads them back as strings.""" + doc = _doc_with_fields({"buttons": {"probe": "L1\nL2", "num": "123", "flag": "yes"}}) + + text = serialize_flow(doc) + + assert "num: '123'" in text + assert "flag: 'yes'" in text + + def test_serialize_buttons_round_trips_through_safe_load(self) -> None: + """A buttons mapping parses back to the original map with all values ``str``.""" + doc = _doc_with_fields({"buttons": {"probe": "L1\nL2", "num": "123", "flag": "yes"}}) + + text = serialize_flow(doc) + loaded = yaml.safe_load(text) + + assert loaded["stages"][0]["buttons"] == {"probe": "L1\nL2", "num": "123", "flag": "yes"} + assert all(isinstance(value, str) for value in loaded["stages"][0]["buttons"].values()) + + def test_serialize_buttons_preserves_insertion_order(self) -> None: + """The buttons mapping serializes in authored order — never sorted. + + The branch rebuilds the dict (wrapping multi-line values), so the + rebuild itself could reorder it; the authored order is what afm + renders as the button order. Non-alphabetical keys make a ``sorted`` + regression observable. + """ + doc = _doc_with_fields({"buttons": {"zebra": "Z", "alpha": "A", "mid": "M"}}) + + text = serialize_flow(doc) + + assert list(yaml.safe_load(text)["stages"][0]["buttons"]) == ["zebra", "alpha", "mid"] diff --git a/tests/pipeline/compiler/test_serialize_flow_memory_slot.py b/tests/pipeline/compiler/test_serialize_flow_memory_slot.py new file mode 100644 index 00000000..718f0667 --- /dev/null +++ b/tests/pipeline/compiler/test_serialize_flow_memory_slot.py @@ -0,0 +1,180 @@ +"""Logic tests for the top-level ``memory`` slot + ``serialize_flow`` emission. + +Covers the memory serializer extension: a non-``None`` ``FlowDocument.memory`` +is emitted between ``description`` and ``stages`` with the fixed key order +``path, mode, memory_use, max_rules, commit`` and a ``None`` field omitted +entirely; a ``None`` memory omits the block entirely — byte-identical output +for memory-free workflows. The per-stage memory keys (``reflect`` mapping, +``memory_use`` bool) ride the existing ``fields`` passthrough: a dict renders +block-style under the default ``beautiful_yaml`` parameters, a bool renders as +a plain scalar — no representer and no ``_build_stage_repr`` change. +""" + +from __future__ import annotations + +from goga.pipeline.compiler import FlowDocument, FlowMemory, FlowStage, serialize_flow + +# Golden byte-identity literal for a memory-free document — frozen from the +# serializer output BEFORE the memory branch existed. Any leak of memory into +# the output (a block, a stage key, a key-order shift) changes this string. +MEMORY_FREE_GOLDEN = ( + "name: demo\n" + "description: Demo pipeline\n" + "stages:\n" + "- id: brainstorm\n" + " name: Brainstorm\n" + " agents: [auto]\n" + " prompt: Think\n" + "- id: build\n" + " name: Build\n" + " agents: [auto]\n" + " prompt: Make\n" + " depends_on:\n" + " - brainstorm\n" +) + + +def _stage(id: str, name: str, fields: dict[str, object], depends_on: list[str] | None = None) -> FlowStage: + """Build one ``FlowStage`` with the given fields (mirrors the compile output shape).""" + return FlowStage(id=id, name=name, depends_on=depends_on, fields=fields) + + +class TestSerializeFlowMemoryBlock: + """Behavioral tests for the top-level memory block emission rules.""" + + def test_serialize_flow_memory_block_position_and_key_order(self) -> None: + """The block sits between ``description`` and ``stages`` with fixed key order, plain scalars.""" + doc = FlowDocument( + name="n", + description="d", + memory=FlowMemory( + path=".goga/memory/x", + mode="rw", + memory_use=True, + max_rules=25, + commit=False, + ), + stages=[_stage("a", "A", {"prompt": "Do"})], + ) + + text = serialize_flow(doc) + + # Position: description < memory < stages. + idx_description = text.index("description:") + idx_memory = text.index("memory:") + idx_stages = text.index("stages:") + assert idx_description < idx_memory < idx_stages + # In-block key order: path < mode < memory_use < max_rules < commit. + idx_path = text.index("path: .goga/memory/x") + idx_mode = text.index("mode: rw") + idx_use = text.index("memory_use: true") + idx_max = text.index("max_rules: 25") + idx_commit = text.index("commit: false") + assert idx_memory < idx_path < idx_mode < idx_use < idx_max < idx_commit + assert idx_commit < idx_stages + # The exact block literal — every value a plain scalar, 2-space indent + # (beautiful_yaml indent=2; the nested stage keys sit at 4, not 2). + assert ( + "memory:\n path: .goga/memory/x\n mode: rw\n memory_use: true\n max_rules: 25\n commit: false\n" + ) in text + + def test_serialize_flow_none_fields_omitted_from_block(self) -> None: + """A ``None`` field is omitted entirely — the reflect-method block carries exactly path/max_rules/commit.""" + doc = FlowDocument( + name="n", + description="d", + memory=FlowMemory(path=".goga/memory", max_rules=9, commit=True), + stages=[_stage("a", "A", {"prompt": "Do"})], + ) + + text = serialize_flow(doc) + + block_text = text[text.index("memory:") : text.index("stages:")] + assert "path: .goga/memory" in block_text + assert "max_rules: 9" in block_text + assert "commit: true" in text + assert "commit: true" in block_text + # None fields never reach the output — no key at all, not an empty value. + assert "mode:" not in block_text + assert "memory_use:" not in block_text + + def test_serialize_flow_document_without_memory_omits_block(self) -> None: + """A ``None`` memory produces no top-level ``memory:`` key at all.""" + doc = FlowDocument(name="n", description="d", memory=None, stages=[_stage("a", "A", {})]) + + text = serialize_flow(doc) + + assert "memory:" not in text + assert "memory" not in text + + +class TestSerializeFlowStageMemoryKeys: + """Behavioral tests for the per-stage memory keys riding the ``fields`` passthrough.""" + + def test_serialize_flow_reflect_is_block_style_mapping(self) -> None: + """A stage ``reflect`` dict renders block-style (4-space nested keys) without any block emitted.""" + doc = FlowDocument( + name="n", + description="d", + stages=[_stage("s", "S", {"reflect": {"file": "shared.md", "mode": "rw"}})], + ) + + text = serialize_flow(doc) + + assert "reflect:" in text + assert "file: shared.md" in text + assert "mode: rw" in text + # Block-style, not flow-style. + assert "reflect: {file" not in text + # The nested keys sit at 4 spaces — the second level under ``stages``. + assert " file: shared.md" in text + assert " mode: rw" in text + # No block emitted -> no stage carries an opting-out key. + assert "memory_use: false" not in text + assert "memory:" not in text + + def test_serialize_flow_memory_use_is_plain_bool_scalar(self) -> None: + """A stage ``memory_use`` bool renders as a plain scalar (true/false, unquoted, not block).""" + doc = FlowDocument( + name="n", + description="d", + stages=[ + _stage("s", "S", {"memory_use": True}), + _stage("t", "T", {"memory_use": False}), + ], + ) + + text = serialize_flow(doc) + + assert "memory_use: true" in text + assert "memory_use: false" in text + assert "memory_use: 'true'" not in text + assert "memory_use: |" not in text + + +class TestSerializeFlowMemoryFreeByteIdentity: + """The byte-identity barrier: a memory-free document serializes unchanged.""" + + def test_serialize_flow_memory_free_document_is_byte_identical(self) -> None: + """A document without memory serializes to the frozen pre-change golden, byte for byte.""" + doc = FlowDocument( + name="demo", + description="Demo pipeline", + memory=None, + stages=[ + _stage("brainstorm", "Brainstorm", {"agents": ["auto"], "prompt": "Think"}), + _stage( + "build", + "Build", + {"agents": ["auto"], "prompt": "Make"}, + depends_on=["brainstorm"], + ), + ], + ) + + text = serialize_flow(doc) + + assert text == MEMORY_FREE_GOLDEN + # No memory key anywhere — top level or stage level. + assert "memory" not in text + assert "reflect" not in text diff --git a/tests/pipeline/test_apply_skip_stages.py b/tests/pipeline/test_apply_skip_stages.py index c74944b7..f1310f73 100644 --- a/tests/pipeline/test_apply_skip_stages.py +++ b/tests/pipeline/test_apply_skip_stages.py @@ -24,6 +24,7 @@ from goga.pipeline.workflow import ( WorkflowDocument, WorkflowExtendStage, + WorkflowMemory, WorkflowStage, ) @@ -48,6 +49,30 @@ def test_apply_skip_stages_return_annotation(self) -> None: acceptable = {"WorkflowDocument | None", WorkflowDocument | None} assert annotation in acceptable + def test_apply_skip_stages_carries_memory_verbatim(self) -> None: + """The rebuild carries the input's memory configuration verbatim. + + The SAME ``WorkflowMemory`` object (not a rebuild) rides into the + returned document — a rebuild that dropped it would silently disable + memory participation for skip-driven runs. + """ + memory = WorkflowMemory(max_rules=7) + workflow = WorkflowDocument( + memory=memory, + stages={"build": WorkflowStage(agent="codex")}, + ) + + result = apply_skip_stages(workflow, ["review"]) + + assert result.memory is memory + + def test_apply_skip_stages_none_input_memory_stays_none(self) -> None: + """A rebuild over a None workflow carries memory=None.""" + result = apply_skip_stages(None, ["x"]) + + assert result is not None + assert result.memory is None + class TestApplySkipStagesLogic: """Logic tests — the verbatim behavior from the design Test Stack Trace.""" @@ -86,6 +111,11 @@ def test_apply_skip_stages_skip_wins_over_existing(self) -> None: assert result.stages["build"].prompt is None assert result.stages["build"].loop is None assert result.stages["build"].skills is None + # The wholesale replacement drops the memory instructions too — the + # WorkflowStage(skip=True) constructor defaults (a skipped stage never + # participates; the compiler removes it before any memory assembly). + assert result.stages["build"].reflect is None + assert result.stages["build"].memory is None def test_apply_skip_stages_none_nonempty_constructs_doc(self) -> None: """A None workflow with non-empty skip constructs a fresh skip-only doc.""" @@ -133,6 +163,23 @@ def test_apply_skip_stages_does_not_mutate_input(self) -> None: assert "test" not in workflow.stages assert set(workflow.stages.keys()) == {"build"} + def test_apply_skip_stages_does_not_mutate_memory(self) -> None: + """The input's memory configuration and stages map survive the rebuild.""" + workflow = WorkflowDocument( + memory=WorkflowMemory(max_rules=7), + stages={"build": WorkflowStage(agent="codex")}, + ) + + result = apply_skip_stages(workflow, ["review"]) + + # The rebuild carries the memory verbatim; the input keeps its own. + assert result.memory == WorkflowMemory(max_rules=7) + assert workflow.memory == WorkflowMemory(max_rules=7) + # The input stages map is unchanged. + assert set(workflow.stages.keys()) == {"build"} + assert workflow.stages["build"].agent == "codex" + assert workflow.stages["build"].skip is False + def test_apply_skip_stages_does_not_validate_names(self) -> None: """An unknown skip name is accepted — validation is the compiler's job.""" workflow = WorkflowDocument(prompt="P", stages={"build": WorkflowStage(agent="codex")}) @@ -150,3 +197,36 @@ def test_apply_skip_stages_idempotent_duplicate_name(self) -> None: assert set(result.stages.keys()) == {"build"} assert result.stages["build"].skip is True + + def test_skip_replaces_notes_entry(self) -> None: + """A skipped name's replacement stage carries notes=None (model default).""" + workflow = WorkflowDocument( + stages={ + "a": WorkflowStage(notes={"x": "1"}), + "b": WorkflowStage(notes={"y": "2"}), + }, + ) + + result = apply_skip_stages(workflow, ["a"]) + + # The wholesale replacement carries skip=True and the notes model + # default (None) — dropped notes are unreachable by design (the + # compiler removes the stage before any notes application). + assert result.stages["a"].notes is None + assert result.stages["a"].skip is True + # The input document is unchanged. + assert workflow.stages["a"].notes == {"x": "1"} + assert workflow.stages["a"].skip is False + + def test_apply_skip_stages_preserves_notes_of_other_stages(self) -> None: + """Surviving stages keep their notes through the shallow stages copy.""" + workflow = WorkflowDocument( + stages={ + "a": WorkflowStage(notes={"x": "1"}), + "b": WorkflowStage(notes={"y": "2"}), + }, + ) + + result = apply_skip_stages(workflow, ["a"]) + + assert result.stages["b"].notes == {"y": "2"} diff --git a/tests/pipeline/workflow/test_parse_workflow_contract.py b/tests/pipeline/workflow/test_parse_workflow_contract.py index 52b7932e..b82dcf76 100644 --- a/tests/pipeline/workflow/test_parse_workflow_contract.py +++ b/tests/pipeline/workflow/test_parse_workflow_contract.py @@ -144,14 +144,28 @@ def test_parse_workflow_stage_keys_includes_approve(self) -> None: Pins the contract: ``_STAGE_KEYS`` is the single source of the accepted per-stage key set and of the unknown-key ``valid keys`` message fragment, - so it must carry ``approve`` (after ``skip``) and ``manual`` (after - ``approve``). + so it must carry ``approve`` (after ``skip``), ``manual`` (after + ``approve``), ``notes`` (after ``manual``), ``reflect`` (after + ``notes``), and ``memory`` (after ``reflect``) — the two + memory-participation instructions close the canonical order. """ from goga.pipeline.workflow.parse_workflow import _STAGE_KEYS assert "approve" in _STAGE_KEYS - # Fixed canonical order: agent, prompt, loop, skills, skip, approve, manual. - assert _STAGE_KEYS == ("agent", "prompt", "loop", "skills", "skip", "approve", "manual") + # Fixed canonical order: agent, prompt, loop, skills, skip, approve, + # manual, notes, reflect, memory. + assert _STAGE_KEYS == ( + "agent", + "prompt", + "loop", + "skills", + "skip", + "approve", + "manual", + "notes", + "reflect", + "memory", + ) def test_parse_workflow_manual_is_accepted_stage_key(self, tmp_path: Path) -> None: """``manual`` is part of the accepted per-stage key set (contract surface). @@ -175,7 +189,7 @@ def test_parse_workflow_unknown_stage_key_message_lists_approve(self, tmp_path: Pins the contract: the ``valid keys`` fragment of the unknown-key message is generated from ``_STAGE_KEYS`` and therefore includes ``approve`` and - the trailing ``manual``. + the trailing ``manual, notes``. """ workflow_path = tmp_path / "workflow.yml" workflow_path.write_text("stages:\n propose:\n bad: value\n") @@ -185,4 +199,4 @@ def test_parse_workflow_unknown_stage_key_message_lists_approve(self, tmp_path: message = str(exc_info.value) assert "unknown key in workflow.stages.propose: bad" in message - assert "valid keys: agent, prompt, loop, skills, skip, approve, manual" in message + assert "valid keys: agent, prompt, loop, skills, skip, approve, manual, notes" in message diff --git a/tests/pipeline/workflow/test_parse_workflow_logic.py b/tests/pipeline/workflow/test_parse_workflow_logic.py index 2ffe5a38..bea863d2 100644 --- a/tests/pipeline/workflow/test_parse_workflow_logic.py +++ b/tests/pipeline/workflow/test_parse_workflow_logic.py @@ -282,6 +282,67 @@ def test_parse_workflow_manual_coexists_with_other_fields(self, tmp_path: Path) assert deploy.approve == "auto" assert deploy.loop == 2 + def test_parse_workflow_notes_map_builds_stage_notes(self, tmp_path: Path) -> None: + """A ``notes`` map on a stage builds WorkflowStage.notes verbatim (authoring order). + + Pins the model invariant: ``notes`` is either ``None`` or a NON-EMPTY + map stored verbatim with insertion order preserved — the compiler + assembles the flow-file buttons from exactly this map. + """ + workflow_path = _write( + tmp_path, + "workflow.yml", + "stages:\n" + " deploy:\n" + " notes:\n" + " fix: Fix the failure and continue\n" + " investigate: Gather diagnostics\n", + ) + + document = parse_workflow(workflow_path) + + deploy = document.stages["deploy"] + assert deploy.notes == { + "fix": "Fix the failure and continue", + "investigate": "Gather diagnostics", + } + # Authoring order is preserved (the serializer emits the map as authored). + assert list(deploy.notes) == ["fix", "investigate"] + # notes is independent of the other fields; they stay at their defaults. + assert deploy.agent is None + assert deploy.skip is False + + def test_parse_workflow_empty_notes_map_normalizes_to_none(self, tmp_path: Path) -> None: + """An empty ``notes`` map normalizes to None (``{}`` equals absence). + + The compiler's ``is not None`` check must never see an empty-map + instruction — an empty map would assemble a pointless ``buttons: {}`` + key into the flow-file. + """ + workflow_path = _write(tmp_path, "workflow.yml", "stages:\n deploy:\n notes: {}\n") + + document = parse_workflow(workflow_path) + + assert document.stages["deploy"].notes is None + + def test_parse_workflow_non_str_note_key_flows_through_verbatim(self, tmp_path: Path) -> None: + """A non-str note KEY is not rejected — the open-key stance. + + Map keys are deliberately unvalidated (afm owns the runtime note + grammar — the same open stance as the agent namespace): a non-str + key flows through verbatim into ``WorkflowStage.notes``. Only the + VALUES are validated at parse time. + """ + workflow_path = _write( + tmp_path, + "workflow.yml", + "stages:\n deploy:\n notes:\n 1: Fix it\n", + ) + + document = parse_workflow(workflow_path) + + assert document.stages["deploy"].notes == {1: "Fix it"} + def test_parse_workflow_extend_populates_document(self, tmp_path: Path) -> None: """A workflow-file with an extend block parses each entry into WorkflowExtendStage.""" workflow_path = _write( @@ -559,10 +620,10 @@ def test_parse_workflow_rejects_unknown_stage_key(self, tmp_path: Path) -> None: message = str(exc_info.value) assert "unknown key in workflow.stages.propose: bad" in message # The full valid-keys list now includes ``skip`` (5th), ``approve`` - # (6th), and ``manual`` (7th); the substring ``agent, prompt, loop, - # skills`` alone would pass even without them, so assert the full - # trailing fragment. - assert "valid keys: agent, prompt, loop, skills, skip, approve, manual" in message + # (6th), ``manual`` (7th), and ``notes`` (8th); the substring + # ``agent, prompt, loop, skills`` alone would pass even without them, + # so assert the full trailing fragment. + assert "valid keys: agent, prompt, loop, skills, skip, approve, manual, notes" in message def test_parse_workflow_rejects_non_str_agent(self, tmp_path: Path) -> None: """A non-str agent raises WorkflowSyntaxError naming the stage and field.""" @@ -707,6 +768,82 @@ def test_parse_workflow_manual_forbidden_in_extend(self, tmp_path: Path) -> None with pytest.raises(WorkflowSyntaxError, match=r"manual is forbidden in workflow\.extend\.extra"): parse_workflow(workflow_path) + @pytest.mark.parametrize( + "notes_yaml", + ["[1, 2]", "~", "text"], + ids=["list", "explicit-null", "str"], + ) + def test_parse_workflow_non_mapping_notes_raises(self, tmp_path: Path, notes_yaml: str) -> None: + """A non-mapping ``notes`` raises WorkflowSyntaxError('non-mapping notes ...'). + + Presence of the key gates the check (like ``skip``/``manual``), so an + explicit ``notes: null`` is ALSO a non-mapping error — unlike + ``trigger`` whose null would be an unknown-key error. The message is + asserted in full: the shape matches ``non-list-of-str skills in ...`` + (a stage-level location with no trailing field name). + """ + workflow_path = _write( + tmp_path, + "workflow.yml", + f"stages:\n deploy:\n notes: {notes_yaml}\n", + ) + + with pytest.raises(WorkflowSyntaxError) as exc_info: + parse_workflow(workflow_path) + + assert str(exc_info.value) == "non-mapping notes in workflow.stages.deploy" + + def test_parse_workflow_non_str_note_value_raises(self, tmp_path: Path) -> None: + """A non-str note value raises WorkflowSyntaxError naming the note key. + + The KEY-interpolated message shape is unique to notes: no other stage + field validates a map's values, so the location fragment ends in the + offending note name (``...stages.deploy.notes.fix``). + """ + workflow_path = _write(tmp_path, "workflow.yml", "stages:\n deploy:\n notes:\n fix: 5\n") + + with pytest.raises(WorkflowSyntaxError) as exc_info: + parse_workflow(workflow_path) + + assert str(exc_info.value) == "non-str value in workflow.stages.deploy.notes.fix" + + def test_parse_workflow_non_str_note_key_interpolated_in_error(self, tmp_path: Path) -> None: + """A failing value under a non-str key names the key verbatim in the location. + + The open-key stance means the error fragment carries whatever key was + authored — the integer key ``1`` lands in the message unchanged + (``...stages.deploy.notes.1``). + """ + workflow_path = _write( + tmp_path, + "workflow.yml", + "stages:\n deploy:\n notes:\n 1: 5\n", + ) + + with pytest.raises(WorkflowSyntaxError) as exc_info: + parse_workflow(workflow_path) + + assert str(exc_info.value) == "non-str value in workflow.stages.deploy.notes.1" + + def test_parse_workflow_notes_forbidden_in_extend_entry(self, tmp_path: Path) -> None: + """A ``notes`` key inside an extend entry raises WorkflowSyntaxError naming the entry. + + notes is stages-block only (it mirrors ``manual``): the compiler + consumes it per stage name, so an extend-stage receives its notes + through the ``stages`` block — never inline. The check sits at contract + position 6.2.5, right after the ``manual`` check (6.2.4). + """ + workflow_path = _write( + tmp_path, + "workflow.yml", + "stages:\n deploy:\n agent: codex\nextend:\n extra:\n after: [deploy]\n notes:\n fix: x\n", + ) + + with pytest.raises(WorkflowSyntaxError) as exc_info: + parse_workflow(workflow_path) + + assert str(exc_info.value) == "notes is forbidden in workflow.extend.extra" + def test_parse_workflow_trigger_in_stages_is_unknown_key(self, tmp_path: Path) -> None: """A ``trigger`` key in the stages block is an unknown-key structural error. @@ -731,7 +868,7 @@ def test_parse_workflow_trigger_unknown_key_message_lists_manual(self, tmp_path: message = str(exc_info.value) assert "unknown key in workflow.stages.deploy: trigger" in message - assert "valid keys: agent, prompt, loop, skills, skip, approve, manual" in message + assert "valid keys: agent, prompt, loop, skills, skip, approve, manual, notes" in message def test_parse_workflow_approve_non_auto_rejected(self, tmp_path: Path) -> None: """An ``approve`` value outside the accepted set is rejected (stages entry). diff --git a/tests/pipeline/workflow/test_parse_workflow_memory.py b/tests/pipeline/workflow/test_parse_workflow_memory.py new file mode 100644 index 00000000..1f9d3e91 --- /dev/null +++ b/tests/pipeline/workflow/test_parse_workflow_memory.py @@ -0,0 +1,379 @@ +"""Contract and logic tests for the memory authoring surface of ``parse_workflow``. + +Covers the memory vocabulary the workflow-cell CODEMANIFEST declares: the +optional top-level ``memory`` block (key set, value domains, path shapes, +materialized defaults), the per-stage ``reflect`` / ``memory`` instructions, +the prohibition of both keys in an extend-entry, the correspondence between +the materialized method and the per-stage instructions, and the new +empty-workflow rule that counts the block. The contract class pins the API +shape; the logic classes exercise every documented message verbatim. +""" + +from __future__ import annotations + +import inspect +import re +from pathlib import Path + +import pytest +from goga.pipeline.workflow import ( + WorkflowDocument, + WorkflowMemory, + WorkflowReflect, + WorkflowStage, + WorkflowSyntaxError, + parse_workflow, +) + + +def _write(tmp_path: Path, name: str, text: str) -> Path: + """Write ``text`` to ``tmp_path / name`` and return the path.""" + workflow_path = tmp_path / name + + workflow_path.write_text(text) + + return workflow_path + + +def _parse(tmp_path: Path, text: str) -> WorkflowDocument: + """Parse ``text`` as a workflow-file and return the built document.""" + return parse_workflow(_write(tmp_path, "workflow.yml", text)) + + +class TestParseWorkflowMemoryContract: + """Contract tests — the memory surface of the ``parse_workflow`` routine.""" + + def test_parse_workflow_signature_unchanged(self) -> None: + """``parse_workflow`` still takes exactly one parameter (``workflow_path``).""" + parameters = list(inspect.signature(parse_workflow).parameters) + + assert parameters == ["workflow_path"] + + def test_parse_workflow_document_memory_is_optional_workflow_memory(self, tmp_path: Path) -> None: + """The parsed document carries ``memory`` as a ``WorkflowMemory | None``.""" + with_block = _parse(tmp_path, "memory:\n max_rules: 40\n") + without_block = _parse(tmp_path, "prompt: guidance\n") + + assert isinstance(with_block.memory, WorkflowMemory) + assert without_block.memory is None + + def test_parse_workflow_stage_reflect_is_optional_workflow_reflect(self, tmp_path: Path) -> None: + """A parsed stage carries ``reflect`` as a ``WorkflowReflect | None``.""" + document = _parse(tmp_path, "stages:\n brainstorm:\n reflect:\n file: shared.md\n") + + assert isinstance(document.stages["brainstorm"].reflect, WorkflowReflect) + assert WorkflowStage().reflect is None + + def test_parse_workflow_stage_memory_is_optional_bool(self, tmp_path: Path) -> None: + """A parsed stage carries ``memory`` as a ``bool | None`` (never ``False``).""" + document = _parse( + tmp_path, + "memory:\n method: alignment\nstages:\n brainstorm:\n memory: true\n review:\n agent: codex\n", + ) + + assert document.stages["brainstorm"].memory is True + assert isinstance(document.stages["brainstorm"].memory, bool) + assert document.stages["review"].memory is None + + +class TestParseWorkflowMemoryPositive: + """Positive logic tests — valid memory authoring parses to the expected models.""" + + def test_parse_workflow_memory_block_only_is_valid_not_empty(self, tmp_path: Path) -> None: + """A workflow of the memory block alone is valid — the block counts as content.""" + document = _parse(tmp_path, "memory:\n max_rules: 40\n") + + assert document.prompt is None + assert document.stages == {} + assert document.extend == {} + assert isinstance(document.memory, WorkflowMemory) + assert document.memory.max_rules == 40 + assert document.memory.method == "reflect" + assert document.memory.commit is False + assert document.memory.mode is None + + def test_parse_workflow_empty_memory_block_materializes_defaults(self, tmp_path: Path) -> None: + """An empty ``memory: {}`` block builds the model with every default materialized.""" + document = _parse(tmp_path, "memory: {}\n") + + assert isinstance(document.memory, WorkflowMemory) + assert document.memory == WorkflowMemory() + assert document.memory.method == "reflect" + assert document.memory.path is None + assert document.memory.max_rules == 25 + assert document.memory.commit is False + assert document.memory.mode is None + + def test_parse_workflow_max_rules_accepts_lower_boundary(self, tmp_path: Path) -> None: + """``max_rules: 1`` — the inclusive lower boundary — parses.""" + document = _parse(tmp_path, "memory:\n max_rules: 1\n") + + assert document.memory is not None + assert document.memory.max_rules == 1 + + def test_parse_workflow_memory_block_absent_leaves_memory_none(self, tmp_path: Path) -> None: + """A workflow-file without a ``memory`` block yields ``document.memory is None``.""" + document = _parse(tmp_path, "prompt: guidance\nstages:\n build:\n agent: codex\n") + + assert document.memory is None + + def test_parse_workflow_alignment_block_materializes_mode_rw(self, tmp_path: Path) -> None: + """The alignment method materializes ``mode`` to ``rw`` when the block omits it.""" + document = _parse( + tmp_path, + "memory:\n method: alignment\n path: goga-development\nstages:\n brainstorm:\n memory: true\n", + ) + + assert document.memory is not None + assert document.memory.method == "alignment" + assert document.memory.mode == "rw" + assert document.memory.path == "goga-development" + assert document.stages["brainstorm"].memory is True + + def test_parse_workflow_stage_reflect_builds_workflow_reflect(self, tmp_path: Path) -> None: + """Per-stage reflect instructions build WorkflowReflect with the mode materialized.""" + document = _parse( + tmp_path, + "memory:\n" + " max_rules: 40\n" + "stages:\n" + " brainstorm:\n" + " reflect:\n" + " file: shared.md\n" + " mode: r\n" + " review:\n" + " reflect:\n" + " file: shared.md\n", + ) + + assert document.stages["brainstorm"].reflect == WorkflowReflect(file="shared.md", mode="r") + assert document.stages["review"].reflect == WorkflowReflect(file="shared.md", mode="rw") + + def test_parse_workflow_stage_reflect_without_memory_block_is_valid(self, tmp_path: Path) -> None: + """A reflect instruction with no authored block is valid — the default method is reflect.""" + document = _parse(tmp_path, "stages:\n brainstorm:\n reflect:\n file: shared.md\n") + + assert document.memory is None + assert document.stages["brainstorm"].reflect == WorkflowReflect(file="shared.md", mode="rw") + + def test_parse_workflow_stage_memory_false_equals_absence(self, tmp_path: Path) -> None: + """An explicit ``memory: false`` is normalized to ``None`` — absence, not False.""" + document = _parse( + tmp_path, + "memory:\n method: alignment\nstages:\n build:\n memory: false\n", + ) + + assert document.stages["build"].memory is None + + +class TestParseWorkflowMemoryBlockRejections: + """Negative logic tests — every structural defect of the ``memory`` block.""" + + @pytest.mark.parametrize( + ("block_yaml", "message"), + [ + ("memory: [1]", "non-mapping memory block in workflow"), + ("memory: text", "non-mapping memory block in workflow"), + ( + "memory: {bad: 1}", + "unknown key in workflow.memory: bad; valid keys: method, path, max_rules, commit, mode", + ), + ("memory: {method: 3}", "non-str value in workflow.memory.method"), + ("memory: {method: sync}", "method must be one of: reflect, alignment in workflow.memory"), + ("memory: {path: 4}", "non-str value in workflow.memory.path"), + ("memory: {path: ''}", "invalid path in workflow.memory.path: "), + ("memory: {path: /abs}", "invalid path in workflow.memory.path: /abs"), + ("memory: {path: ../x}", "invalid path in workflow.memory.path: ../x"), + ("memory: {max_rules: '9'}", "non-int value in workflow.memory.max_rules"), + ("memory: {max_rules: 0}", "max_rules must be >= 1 in workflow.memory"), + ("memory: {max_rules: -3}", "max_rules must be >= 1 in workflow.memory"), + ("memory: {max_rules: true}", "non-int value in workflow.memory.max_rules"), + ("memory: {commit: 'yes'}", "non-bool value in workflow.memory.commit"), + ("memory: {commit: 1}", "non-bool value in workflow.memory.commit"), + ("memory: {mode: 1}", "non-str value in workflow.memory.mode"), + ("memory: {mode: x}", "mode must be one of: r, w, rw in workflow.memory"), + ("memory: {method: reflect, mode: rw}", "mode is forbidden in workflow.memory with method: reflect"), + ], + ids=[ + "list-block", + "scalar-block", + "unknown-key", + "non-str-method", + "method-outside-domain", + "non-str-path", + "empty-path", + "absolute-path", + "parent-segment-path", + "non-int-max-rules", + "zero-max-rules", + "negative-max-rules", + "bool-max-rules", + "non-bool-commit", + "int-commit", + "non-str-mode", + "mode-outside-domain", + "mode-under-reflect", + ], + ) + def test_parse_workflow_rejects_memory_block_shape_errors( + self, + tmp_path: Path, + block_yaml: str, + message: str, + ) -> None: + """A structurally malformed memory block raises WorkflowSyntaxError with the documented message.""" + with pytest.raises(WorkflowSyntaxError, match=re.escape(message)): + _parse(tmp_path, f"{block_yaml}\n") + + def test_parse_workflow_unknown_top_level_message_lists_memory(self, tmp_path: Path) -> None: + """The unknown top-level key message now lists ``memory`` in the valid-keys fragment.""" + with pytest.raises(WorkflowSyntaxError) as exc_info: + _parse(tmp_path, "bogus: 1\n") + + message = str(exc_info.value) + assert "unknown key in workflow: bogus" in message + assert "valid keys: prompt, stages, extend, memory" in message + + def test_parse_workflow_unknown_stage_key_message_lists_reflect_and_memory( + self, + tmp_path: Path, + ) -> None: + """The unknown per-stage key message now lists ``reflect, memory`` in the valid-keys fragment.""" + with pytest.raises(WorkflowSyntaxError) as exc_info: + _parse(tmp_path, "stages:\n build:\n bogus: 1\n") + + message = str(exc_info.value) + assert "unknown key in workflow.stages.build: bogus" in message + assert "valid keys: agent, prompt, loop, skills, skip, approve, manual, notes, reflect, memory" in message + + +class TestParseWorkflowReflectRejections: + """Negative logic tests — every structural defect of a per-stage ``reflect`` instruction.""" + + @pytest.mark.parametrize( + ("stage_yaml", "block_yaml", "message"), + [ + (" reflect: [1]", "", "non-mapping reflect in workflow.stages.brainstorm"), + ( + " reflect:\n file: a.md\n bad: 1", + "", + "unknown key in workflow.stages.brainstorm.reflect: bad; valid keys: file, mode", + ), + (" reflect: {}", "", "file is required in workflow.stages.brainstorm.reflect"), + (" reflect:\n mode: r", "", "file is required in workflow.stages.brainstorm.reflect"), + ( + " reflect:\n file: 3", + "", + "non-str value in workflow.stages.brainstorm.reflect.file", + ), + ( + " reflect:\n file: ../x", + "", + "invalid path in workflow.stages.brainstorm.reflect.file: ../x", + ), + ( + " reflect:\n file: a.md\n mode: 3", + "", + "non-str value in workflow.stages.brainstorm.reflect.mode", + ), + ( + " reflect:\n file: a.md\n mode: x", + "", + "mode must be one of: r, w, rw in workflow.stages.brainstorm.reflect", + ), + (" memory: 1", "memory:\n method: alignment\n", "non-bool value in workflow.stages.brainstorm.memory"), + ], + ids=[ + "list-reflect", + "unknown-key", + "empty-map", + "missing-file", + "non-str-file", + "bad-path-shape", + "non-str-mode", + "mode-outside-domain", + "non-bool-memory", + ], + ) + def test_parse_workflow_rejects_reflect_instruction_errors( + self, + tmp_path: Path, + stage_yaml: str, + block_yaml: str, + message: str, + ) -> None: + """A malformed per-stage instruction raises WorkflowSyntaxError with the documented message.""" + text = f"stages:\n brainstorm:\n{stage_yaml}\n" + + if block_yaml: + text = f"{block_yaml}{text}" + + with pytest.raises(WorkflowSyntaxError, match=re.escape(message)): + _parse(tmp_path, text) + + +class TestParseWorkflowMethodInstructionMismatch: + """Negative logic tests — the method ↔ instruction correspondence and the extend prohibition.""" + + @pytest.mark.parametrize( + ("text", "message"), + [ + ( + "memory:\n method: alignment\nstages:\n x:\n reflect:\n file: a.md\n", + "reflect is forbidden in workflow.stages.x with method: alignment", + ), + ( + "memory: {}\nstages:\n x:\n memory: true\n", + "memory is forbidden in workflow.stages.x with method: reflect", + ), + ( + "stages:\n x:\n memory: true\n", + "memory is forbidden in workflow.stages.x with method: reflect", + ), + ( + "extend:\n extra:\n after: [build]\n title: Extra\n reflect:\n file: a.md\n", + "reflect is forbidden in workflow.extend.extra", + ), + ( + "extend:\n extra:\n after: [build]\n title: Extra\n memory: true\n", + "memory is forbidden in workflow.extend.extra", + ), + ], + ids=[ + "reflect-under-alignment", + "memory-under-reflect-with-block", + "memory-under-reflect-without-block", + "reflect-in-extend-entry", + "memory-in-extend-entry", + ], + ) + def test_parse_workflow_rejects_method_instruction_mismatch( + self, + tmp_path: Path, + text: str, + message: str, + ) -> None: + """A method/instruction mismatch or an extend-entry instruction raises the documented message.""" + with pytest.raises(WorkflowSyntaxError, match=re.escape(message)): + _parse(tmp_path, text) + + def test_parse_workflow_alignment_accepts_authored_mode_and_path(self, tmp_path: Path) -> None: + """An alignment block with an authored mode carries it verbatim alongside the suffix.""" + document = _parse( + tmp_path, + "memory:\n method: alignment\n path: p\n mode: r\nstages:\n build:\n memory: true\n", + ) + + assert document.memory is not None + assert document.memory.mode == "r" + assert document.memory.path == "p" + assert document.stages["build"].memory is True + + def test_parse_workflow_empty_workflow_message_mentions_memory_block(self, tmp_path: Path) -> None: + """The empty-workflow message now offers the memory block as a fourth alternative.""" + with pytest.raises(WorkflowSyntaxError) as exc_info: + _parse(tmp_path, "stages: {}\n") + + assert str(exc_info.value) == ( + "empty workflow — provide at least prompt, one stage, one extend entry, or the memory block" + ) diff --git a/tests/pipeline/workflow/test_workflow_document_contract.py b/tests/pipeline/workflow/test_workflow_document_contract.py index 2bfbe58b..0d6812e8 100644 --- a/tests/pipeline/workflow/test_workflow_document_contract.py +++ b/tests/pipeline/workflow/test_workflow_document_contract.py @@ -1,19 +1,23 @@ """Contract tests for the ``WorkflowDocument`` dataclass. Verifies the public API declared by the workflow-cell CODEMANIFEST: -importability from the facade, the three declared properties (``prompt``, -``stages``, and ``extend``), the ``prompt=None`` / ``stages={}`` / -``extend={}`` defaults (the factories are applied at construction, so the -default ``stages``/``extend`` are empty dicts, not None), and kw_only -construction with explicit stages and extend maps. These tests pin the -contract surface — behavior lives in the logic test module. +importability from the facade, the declared properties (``prompt``, +``stages``, ``extend``, and ``memory``), the ``prompt=None`` / ``stages={}`` / +``extend={}`` / ``memory=None`` defaults (the factories are applied at +construction, so the default ``stages``/``extend`` are empty dicts, not None), +the fixed field order, and kw_only construction with explicit stages and +extend maps. These tests pin the contract surface — behavior lives in the +logic test module. """ from __future__ import annotations +import dataclasses + from goga.pipeline.workflow import ( WorkflowDocument, WorkflowExtendStage, + WorkflowMemory, WorkflowStage, ) @@ -78,8 +82,38 @@ def test_workflow_document_default_extend_is_empty_dict(self) -> None: def test_workflow_document_constructible_kw_only_with_explicit_extend(self) -> None: """WorkflowDocument accepts extend as a keyword-only argument and stores the map.""" extend = {"extra": WorkflowExtendStage(after=["review"], body={"title": "Extra"})} - document = WorkflowDocument(extend=extend) + document = WorkflowDocument(prompt="guidance", stages={}, extend=extend, memory=None) assert set(document.extend) == {"extra"} assert document.extend["extra"].after == ["review"] assert document.extend["extra"].body == {"title": "Extra"} + assert document.memory is None + + def test_workflow_document_has_memory_property(self) -> None: + """WorkflowDocument exposes a ``memory`` property defaulting to None.""" + assert hasattr(WorkflowDocument(), "memory") + assert WorkflowDocument().memory is None + assert WorkflowDocument(memory=WorkflowMemory()).memory == WorkflowMemory() + + def test_workflow_document_memory_defaults_to_none(self) -> None: + """The default ``memory`` is None — no workflow-memory block was authored.""" + document = WorkflowDocument() + + assert document.memory is None + # A document built with stages/extend and no memory keeps the default. + assert WorkflowDocument(stages={"build": WorkflowStage()}).memory is None + assert WorkflowDocument(extend={"x": WorkflowExtendStage(body={})}).memory is None + + def test_workflow_document_field_order_fixed(self) -> None: + """Field order is fixed: prompt, stages, extend, memory.""" + names = [field.name for field in dataclasses.fields(WorkflowDocument)] + + assert names == ["prompt", "stages", "extend", "memory"] + + def test_workflow_document_constructible_kw_only_with_memory(self) -> None: + """WorkflowDocument accepts memory as a keyword-only argument, stored verbatim.""" + memory = WorkflowMemory(method="alignment", path="goga-development") + document = WorkflowDocument(memory=memory) + + assert document.memory is memory + assert document.memory == WorkflowMemory(method="alignment", path="goga-development") diff --git a/tests/pipeline/workflow/test_workflow_document_logic.py b/tests/pipeline/workflow/test_workflow_document_logic.py index 023e3485..bb872e0b 100644 --- a/tests/pipeline/workflow/test_workflow_document_logic.py +++ b/tests/pipeline/workflow/test_workflow_document_logic.py @@ -12,6 +12,7 @@ from goga.pipeline.workflow import ( WorkflowDocument, WorkflowExtendStage, + WorkflowMemory, WorkflowStage, ) @@ -103,3 +104,39 @@ def test_equality_of_identical_documents(self) -> None: second = WorkflowDocument(prompt="guidance", stages=dict(stages)) assert first == second + + def test_workflow_document_memory_defaults_none(self) -> None: + """The default ``memory`` is None — no workflow-memory block was authored.""" + assert WorkflowDocument().memory is None + assert WorkflowDocument(prompt="guidance").memory is None + assert WorkflowDocument(stages={"x": WorkflowStage()}).memory is None + assert WorkflowDocument(extend={"y": WorkflowExtendStage(body={})}).memory is None + + def test_workflow_document_memory_stored_verbatim(self) -> None: + """The supplied ``memory`` is stored verbatim — the same object, not a copy.""" + memory = WorkflowMemory(method="alignment", path="goga-development", max_rules=7) + document = WorkflowDocument(memory=memory) + + assert document.memory is memory + assert document.memory == WorkflowMemory(method="alignment", path="goga-development", max_rules=7) + + def test_workflow_document_memory_block_alone_is_a_document(self) -> None: + """A document carrying only the memory block is a valid parsed shape. + + The empty-workflow rule counts the block (``parse_workflow`` enforces + it, Task 3): the model itself accepts a block-only document. + """ + document = WorkflowDocument(memory=WorkflowMemory(max_rules=40)) + + assert document.prompt is None + assert document.stages == {} + assert document.extend == {} + assert document.memory == WorkflowMemory(max_rules=40) + + def test_workflow_document_memory_does_not_leak_into_other_instances(self) -> None: + """A default-constructed document never inherits another document's memory.""" + first = WorkflowDocument(memory=WorkflowMemory()) + second = WorkflowDocument() + + assert first.memory is not None + assert second.memory is None diff --git a/tests/pipeline/workflow/test_workflow_memory_contract.py b/tests/pipeline/workflow/test_workflow_memory_contract.py new file mode 100644 index 00000000..244b2868 --- /dev/null +++ b/tests/pipeline/workflow/test_workflow_memory_contract.py @@ -0,0 +1,66 @@ +"""Contract tests for the ``WorkflowMemory`` dataclass. + +Verifies the public API declared by the workflow-cell CODEMANIFEST: +importability from the facade (including the ``__all__`` obligation), the five +declared properties, the fixed field order, and kw_only construction. These +tests pin the contract surface — behavior lives in the logic test module. +""" + +from __future__ import annotations + +from dataclasses import fields + +import pytest +from goga.pipeline.workflow import WorkflowMemory + + +class TestWorkflowMemoryContract: + """Contract tests — the public API declared by the workflow-cell CODEMANIFEST.""" + + def test_workflow_memory_importable_from_facade(self) -> None: + """WorkflowMemory is importable from the facade and listed in ``__all__``.""" + import goga.pipeline.workflow as facade + + assert facade.WorkflowMemory is WorkflowMemory + assert "WorkflowMemory" in facade.__all__ + + def test_workflow_memory_has_method_property(self) -> None: + """WorkflowMemory exposes a ``method`` property defaulting to "reflect".""" + assert hasattr(WorkflowMemory(), "method") + assert WorkflowMemory().method == "reflect" + assert WorkflowMemory(method="alignment").method == "alignment" + + def test_workflow_memory_has_path_property(self) -> None: + """WorkflowMemory exposes a ``path`` property defaulting to None.""" + assert hasattr(WorkflowMemory(), "path") + assert WorkflowMemory().path is None + assert WorkflowMemory(path="goga-development").path == "goga-development" + + def test_workflow_memory_has_max_rules_property(self) -> None: + """WorkflowMemory exposes a ``max_rules`` property defaulting to 25.""" + assert hasattr(WorkflowMemory(), "max_rules") + assert WorkflowMemory().max_rules == 25 + assert WorkflowMemory(max_rules=40).max_rules == 40 + + def test_workflow_memory_has_commit_property(self) -> None: + """WorkflowMemory exposes a ``commit`` property defaulting to False.""" + assert hasattr(WorkflowMemory(), "commit") + assert WorkflowMemory().commit is False + assert WorkflowMemory(commit=True).commit is True + + def test_workflow_memory_has_mode_property(self) -> None: + """WorkflowMemory exposes a ``mode`` property defaulting to None.""" + assert hasattr(WorkflowMemory(), "mode") + assert WorkflowMemory().mode is None + assert WorkflowMemory(mode="rw").mode == "rw" + + def test_workflow_memory_field_order_fixed(self) -> None: + """Field order is fixed: method, path, max_rules, commit, mode.""" + names = [field.name for field in fields(WorkflowMemory)] + + assert names == ["method", "path", "max_rules", "commit", "mode"] + + def test_workflow_memory_constructible_kw_only(self) -> None: + """WorkflowMemory is keyword-only — positional construction raises TypeError.""" + with pytest.raises(TypeError): + WorkflowMemory("reflect") # type: ignore[misc] diff --git a/tests/pipeline/workflow/test_workflow_memory_logic.py b/tests/pipeline/workflow/test_workflow_memory_logic.py new file mode 100644 index 00000000..97470cee --- /dev/null +++ b/tests/pipeline/workflow/test_workflow_memory_logic.py @@ -0,0 +1,51 @@ +"""Logic tests for the ``WorkflowMemory`` dataclass. + +Covers construction behavior beyond the contract surface: the materialized +defaults (values, not omissions) and the verbatim round-trip of every authored +field. The defaults pin is load-bearing downstream — the consumer +default-constructs ``WorkflowMemory()`` when the workflow carries no block and +sources ``max_rules`` / ``commit`` (and the default method) from the field +defaults, so a silent shift of these values would change compiled output. +""" + +from __future__ import annotations + +from goga.pipeline.workflow import WorkflowMemory + + +class TestWorkflowMemoryLogic: + """Logic tests — construction behavior of the ``WorkflowMemory`` dataclass.""" + + def test_workflow_memory_defaults_are_materialized(self) -> None: + """Constructing with no arguments yields the materialized authoring defaults. + + method "reflect", path None, max_rules 25, commit False, mode None — + the values the emission case «no ``memory:`` block authored» sources. + The pin prevents a silent drift of the defaults into compiled output. + """ + config = WorkflowMemory() + + assert config.method == "reflect" + assert config.path is None + assert config.max_rules == 25 + assert config.commit is False + assert config.mode is None + + def test_workflow_memory_stores_authored_values_verbatim(self) -> None: + """Every field round-trips an authored block verbatim (no composition, no rewrite).""" + config = WorkflowMemory(method="alignment", path="p", max_rules=9, commit=True, mode="r") + + assert config.method == "alignment" + assert config.path == "p" + assert config.max_rules == 9 + assert config.commit is True + assert config.mode == "r" + + def test_equality_of_identical_constructions(self) -> None: + """Two configurations with identical fields compare equal. + + The no-block default equals an explicitly authored ``memory: {}`` — + both describe the same configuration, so the parser's materialized + block and the consumer's default construction must compare equal. + """ + assert WorkflowMemory() == WorkflowMemory(method="reflect", path=None, max_rules=25, commit=False, mode=None) diff --git a/tests/pipeline/workflow/test_workflow_reflect_contract.py b/tests/pipeline/workflow/test_workflow_reflect_contract.py new file mode 100644 index 00000000..ae0f8b4b --- /dev/null +++ b/tests/pipeline/workflow/test_workflow_reflect_contract.py @@ -0,0 +1,49 @@ +"""Contract tests for the ``WorkflowReflect`` dataclass. + +Verifies the public API declared by the workflow-cell CODEMANIFEST: +importability from the facade (including the ``__all__`` obligation), the two +declared properties, the fixed field order, and kw_only construction. These +tests pin the contract surface — behavior lives in the logic test module. +""" + +from __future__ import annotations + +from dataclasses import fields + +import pytest +from goga.pipeline.workflow import WorkflowReflect + + +class TestWorkflowReflectContract: + """Contract tests — the public API declared by the workflow-cell CODEMANIFEST.""" + + def test_workflow_reflect_importable_from_facade(self) -> None: + """WorkflowReflect is importable from the facade and listed in ``__all__``.""" + import goga.pipeline.workflow as facade + + assert facade.WorkflowReflect is WorkflowReflect + assert "WorkflowReflect" in facade.__all__ + + def test_workflow_reflect_has_file_property(self) -> None: + """WorkflowReflect exposes a ``file`` property.""" + reflect = WorkflowReflect(file="a.md") + + assert hasattr(reflect, "file") + assert reflect.file == "a.md" + + def test_workflow_reflect_has_mode_property(self) -> None: + """WorkflowReflect exposes a ``mode`` property defaulting to the materialized "rw".""" + assert hasattr(WorkflowReflect(file="a.md"), "mode") + assert WorkflowReflect(file="a.md").mode == "rw" + assert WorkflowReflect(file="a.md", mode="r").mode == "r" + + def test_workflow_reflect_field_order_fixed(self) -> None: + """Field order is fixed: file, mode.""" + names = [field.name for field in fields(WorkflowReflect)] + + assert names == ["file", "mode"] + + def test_workflow_reflect_constructible_kw_only(self) -> None: + """WorkflowReflect is keyword-only — positional construction raises TypeError.""" + with pytest.raises(TypeError): + WorkflowReflect("a.md") # type: ignore[misc] diff --git a/tests/pipeline/workflow/test_workflow_reflect_logic.py b/tests/pipeline/workflow/test_workflow_reflect_logic.py new file mode 100644 index 00000000..8b089288 --- /dev/null +++ b/tests/pipeline/workflow/test_workflow_reflect_logic.py @@ -0,0 +1,44 @@ +"""Logic tests for the ``WorkflowReflect`` dataclass. + +Covers construction behavior beyond the contract surface: the materialized +``mode`` default (a value, not an omission) and the verbatim round-trip of +authored values (the dataclass is not frozen, but supplied values must +round-trip unchanged). +""" + +from __future__ import annotations + +from goga.pipeline.workflow import WorkflowReflect + + +class TestWorkflowReflectLogic: + """Logic tests — construction behavior of the ``WorkflowReflect`` dataclass.""" + + def test_workflow_reflect_mode_defaults_to_rw(self) -> None: + """Omitting ``mode`` yields the materialized "rw" — not None. + + The contract materializes the default: «materialized to "rw" when the + authoring entry omits it». A consumer reading the model must never + distinguish an authored ``rw`` from an omitted mode, so the default + pins a value rather than an absence. + """ + reflect = WorkflowReflect(file="shared.md") + + assert reflect.file == "shared.md" + assert reflect.mode == "rw" + + def test_workflow_reflect_stores_authored_values_verbatim(self) -> None: + """Both fields round-trip authored values verbatim (no path or mode rewriting).""" + reflect = WorkflowReflect(file="a.md", mode="r") + + assert (reflect.file, reflect.mode) == ("a.md", "r") + + def test_equality_of_identical_constructions(self) -> None: + """Two instructions with identical fields compare equal. + + The omitted-mode and explicit-``rw`` constructions describe the same + instruction and must compare equal — the materialized default is + indistinguishable from the authored value. + """ + assert WorkflowReflect(file="a.md") == WorkflowReflect(file="a.md", mode="rw") + assert WorkflowReflect(file="a.md", mode="r") != WorkflowReflect(file="a.md") diff --git a/tests/pipeline/workflow/test_workflow_stage_contract.py b/tests/pipeline/workflow/test_workflow_stage_contract.py index 86211b78..02a2da26 100644 --- a/tests/pipeline/workflow/test_workflow_stage_contract.py +++ b/tests/pipeline/workflow/test_workflow_stage_contract.py @@ -1,14 +1,16 @@ """Contract tests for the ``WorkflowStage`` dataclass. Verifies the public API declared by the workflow-cell CODEMANIFEST: -importability from the facade, the five declared properties, the all-``None`` -defaults (``skip`` defaults to ``False``), and kw_only construction. These tests -pin the contract surface — behavior lives in the logic test module. +importability from the facade, the declared properties (``agent``, ``prompt``, +``loop``, ``skills``, ``skip``, ``approve``, ``manual``, ``notes``, ``reflect``, +``memory``), the all-``None`` defaults (``skip`` defaults to ``False``), and +kw_only construction. These tests pin the contract surface — behavior lives in +the logic test module. """ from __future__ import annotations -from goga.pipeline.workflow import WorkflowStage +from goga.pipeline.workflow import WorkflowReflect, WorkflowStage class TestWorkflowStageContract: @@ -68,6 +70,40 @@ def test_workflow_stage_has_manual_property(self) -> None: assert WorkflowStage(manual=True).manual is True assert WorkflowStage(manual=False).manual is False + def test_workflow_stage_has_notes_property(self) -> None: + """WorkflowStage exposes a ``notes`` property defaulting to None (NOT {}). + + The ``None`` default pins the "None | non-empty map" model invariant — + ``parse_workflow`` normalizes an empty map to ``None`` upstream, and a + mutable ``{}`` default would silently diverge from the ``is not None`` + check the compiler relies on. + """ + assert hasattr(WorkflowStage(), "notes") + assert WorkflowStage().notes is None + assert WorkflowStage(notes={"fix": "F"}).notes == {"fix": "F"} + + def test_workflow_stage_has_reflect_property(self) -> None: + """WorkflowStage exposes a ``reflect`` property defaulting to None. + + The property carries the optional memory-reflection instruction (a + :class:`WorkflowReflect`) — declarative, consumed by the compiler to + emit the stage's ``reflect`` field. + """ + assert hasattr(WorkflowStage(), "reflect") + assert WorkflowStage().reflect is None + assert WorkflowStage(reflect=WorkflowReflect(file="a.md")).reflect == WorkflowReflect(file="a.md") + + def test_workflow_stage_has_memory_property(self) -> None: + """WorkflowStage exposes a ``memory`` property defaulting to None. + + The property is tri-state in the authoring file but two-valued in the + model — ``None`` (no instruction) or ``True``; an explicit + ``memory: false`` is normalized to ``None`` by ``parse_workflow``. + """ + assert hasattr(WorkflowStage(), "memory") + assert WorkflowStage().memory is None + assert WorkflowStage(memory=True).memory is True + def test_workflow_stage_defaults_all_none(self) -> None: """Every field defaults to None when constructed with no arguments.""" stage = WorkflowStage() @@ -79,7 +115,7 @@ def test_workflow_stage_defaults_all_none(self) -> None: assert stage.approve is None def test_workflow_stage_constructible_kw_only(self) -> None: - """WorkflowStage accepts all seven fields as keyword-only arguments.""" + """WorkflowStage accepts all ten fields as keyword-only arguments.""" stage = WorkflowStage( agent="codex", prompt="text", @@ -88,6 +124,9 @@ def test_workflow_stage_constructible_kw_only(self) -> None: skip=True, approve="auto", manual=True, + notes={"fix": "Fix and continue"}, + reflect=WorkflowReflect(file="a.md"), + memory=True, ) assert stage.agent == "codex" @@ -97,3 +136,6 @@ def test_workflow_stage_constructible_kw_only(self) -> None: assert stage.skip is True assert stage.approve == "auto" assert stage.manual is True + assert stage.notes == {"fix": "Fix and continue"} + assert stage.reflect == WorkflowReflect(file="a.md") + assert stage.memory is True diff --git a/tests/pipeline/workflow/test_workflow_stage_logic.py b/tests/pipeline/workflow/test_workflow_stage_logic.py index 43a3f0ae..1c9848da 100644 --- a/tests/pipeline/workflow/test_workflow_stage_logic.py +++ b/tests/pipeline/workflow/test_workflow_stage_logic.py @@ -10,7 +10,7 @@ from dataclasses import fields -from goga.pipeline.workflow import WorkflowStage +from goga.pipeline.workflow import WorkflowReflect, WorkflowStage class TestWorkflowStageLogic: @@ -114,10 +114,26 @@ def test_workflow_stage_skip_defaults_false(self) -> None: assert WorkflowStage(skip=True).skip is True def test_field_order_fixed_canonical(self) -> None: - """Field order is fixed: agent, prompt, loop, skills, skip, approve, manual.""" + """Field order is fixed across all ten fields. + + agent, prompt, loop, skills, skip, approve, manual, notes, reflect, + memory — the memory instructions occupy the two final canonical + slots, matching the per-stage key order of the workflow-file. + """ names = [field.name for field in fields(WorkflowStage)] - assert names == ["agent", "prompt", "loop", "skills", "skip", "approve", "manual"] + assert names == [ + "agent", + "prompt", + "loop", + "skills", + "skip", + "approve", + "manual", + "notes", + "reflect", + "memory", + ] def test_workflow_stage_approve_defaults_none(self) -> None: """Omitting ``approve`` yields None — no auto-approval directive.""" @@ -158,6 +174,19 @@ def test_workflow_stage_manual_defaults_none_not_false(self) -> None: assert WorkflowStage(manual=True).manual is True assert WorkflowStage(manual=False).manual is False + def test_workflow_stage_notes_defaults_none(self) -> None: + """Omitting ``notes`` yields None — no note-buttons instruction.""" + assert WorkflowStage().notes is None + assert WorkflowStage(agent="codex").notes is None + + def test_workflow_stage_notes_stored_verbatim(self) -> None: + """notes stores the keyword-passed map verbatim (same object, mirroring skills).""" + notes = {"fix": "F"} + stage = WorkflowStage(notes=notes) + + assert stage.notes == {"fix": "F"} + assert stage.notes is notes + def test_skip_accepts_true_and_false(self) -> None: """skip=True and skip=False round-trip verbatim.""" assert WorkflowStage(skip=True).skip is True @@ -179,3 +208,45 @@ def test_all_defaults_construction_yields_skip_false(self) -> None: assert stage == WorkflowStage(agent=None, prompt=None, loop=None, skills=None) assert stage.skip is False + + def test_workflow_stage_reflect_defaults_none(self) -> None: + """Omitting ``reflect`` yields None — no memory-reflection instruction.""" + assert WorkflowStage().reflect is None + assert WorkflowStage(agent="codex").reflect is None + assert WorkflowStage(skip=True).reflect is None + + def test_workflow_stage_reflect_stored_verbatim(self) -> None: + """reflect stores the supplied WorkflowReflect verbatim (same object).""" + reflect = WorkflowReflect(file="shared.md", mode="r") + stage = WorkflowStage(reflect=reflect) + + assert stage.reflect == WorkflowReflect(file="shared.md", mode="r") + assert stage.reflect is reflect + + def test_workflow_stage_memory_defaults_none_not_false(self) -> None: + """memory defaults to None (NOT False) — absence is the only "no" state. + + Unlike ``manual`` (whose tri-state keeps an explicit false + distinguishable), an explicit ``memory: false`` equals absence: + ``parse_workflow`` normalizes it to ``None`` (Task 3), so this field + carries either ``None`` or ``True``. + """ + assert WorkflowStage().memory is None + assert WorkflowStage(skip=True).memory is None + assert WorkflowStage(agent="codex").memory is None + + def test_workflow_stage_memory_true_stored_verbatim(self) -> None: + """memory=True round-trips verbatim and coexists with the other fields.""" + stage = WorkflowStage(agent="codex", memory=True) + + assert stage.memory is True + assert stage.agent == "codex" + assert WorkflowStage(memory=True).memory is True + + def test_reflect_and_memory_occupy_final_two_slots(self) -> None: + """reflect and memory sit immediately after notes, in that order.""" + names = [field.name for field in fields(WorkflowStage)] + + assert names.index("notes") == 7 + assert names.index("reflect") == 8 + assert names.index("memory") == 9 diff --git a/tests/ralphex/test_run_ralphex.py b/tests/ralphex/test_run_ralphex.py index 311fed20..d2a26e46 100644 --- a/tests/ralphex/test_run_ralphex.py +++ b/tests/ralphex/test_run_ralphex.py @@ -59,8 +59,12 @@ def test_scalar_option_emits_flag_with_value(self) -> None: assert "10" in cmd def test_scalar_option_zero_and_empty_omitted(self) -> None: - """Scalar values of None/""/0 are omitted (guards against 0==False regression).""" - assert _build_command("plan.md", {"max_iterations": 0, "session_timeout": ""}) == [ + """Scalar values of None/""/0 are omitted (guards against 0==False regression). + + review_patience 0 is the documented patience-unset case: the resolver + forwards a CLI/config 0 verbatim and the launcher drops it, so ralphex + runs its own default (0 = disabled).""" + assert _build_command("plan.md", {"max_iterations": 0, "session_timeout": "", "review_patience": 0}) == [ "ralphex", "plan.md", "--config-dir", @@ -93,6 +97,7 @@ def test_bool_flag_mapping_is_exact(self, key: str, flag: str) -> None: ("wait", "--wait", "60s"), ("max_iterations", "--max-iterations", 10), ("review_patience", "--review-patience", 3), + ("base_ref", "--base-ref", "origin/1.2.x"), ], ) def test_scalar_flag_mapping_is_exact(self, key: str, flag: str, value: object) -> None: @@ -102,6 +107,19 @@ def test_scalar_flag_mapping_is_exact(self, key: str, flag: str, value: object) assert flag in cmd assert str(value) in cmd + @pytest.mark.parametrize( + "value", + [None, ""], + ids=["none", "empty_string"], + ) + def test_base_ref_unset_omits_flag(self, value: str | None) -> None: + """An unset base_ref adds no token: neither --base-ref nor any value + token reaches the argv (pins the scalar omit rule for the new key).""" + cmd = _build_command("plan.md", {"base_ref": value}) + + assert cmd == ["ralphex", "plan.md", "--config-dir", ".ralphex/"] + assert "--base-ref" not in cmd + class TestRunRalphexLogic: def test_run_ralphex_dry_run_prints_command_and_returns_0(self, capsys: pytest.CaptureFixture[str]) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 74fa88c0..6b94b217 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,14 +2,19 @@ import inspect import json +import runpy +import sys from importlib.metadata import PackageNotFoundError from pathlib import Path +from types import ModuleType +from typing import Any from unittest import mock import click import pytest from click.testing import CliRunner -from goga import app +from goga import app, commands +from goga import cli as cli_module from goga.cli import app as cli_app from tests.conftest import cwd as _cwd @@ -45,6 +50,18 @@ def test_both_imports_reference_same_object(self) -> None: assert app is cli_app +class TestModuleEntrypoint: + def test_python_dash_m_goga_runs_the_root_app(self, monkeypatch: pytest.MonkeyPatch) -> None: + """``python -m goga`` dispatches to the root app — the workflow's entrypoint.""" + monkeypatch.setattr(sys, "argv", ["goga", "history", "path", "-f", "plan.md"]) + sys.modules.pop("goga.__main__", None) + + with mock.patch.object(cli_module, "app", return_value=0) as app_mock: + runpy.run_module("goga", run_name="__main__") + + assert app_mock.call_args == mock.call() + + class TestApiShape: def test_app_is_click_group(self) -> None: """The app object is a click Group instance.""" @@ -88,6 +105,16 @@ def test_uninstall_command_registered(self) -> None: """The 'uninstall' command is registered on the app group.""" assert "uninstall" in app.commands + def test_topics_command_registered(self) -> None: + """The 'topics' command is registered on the app group (command.name).""" + assert any(command.name == "topics" for command in app.commands.values()) + assert "topics" in app.commands + + def test_hooks_command_registered(self) -> None: + """The 'hooks' command is registered on the app group (command.name).""" + assert any(command.name == "hooks" for command in app.commands.values()) + assert "hooks" in app.commands + class TestHelpOutput: def test_help_exit_code_zero(self) -> None: @@ -133,6 +160,18 @@ def test_help_contains_uninstall(self) -> None: result = runner.invoke(app, ["--help"]) assert "uninstall" in result.output + def test_help_contains_topics(self) -> None: + """The --help output lists the 'topics' command.""" + runner = CliRunner() + result = runner.invoke(app, ["--help"]) + assert "topics" in result.output + + def test_help_contains_hooks(self) -> None: + """The --help output lists the 'hooks' command (design checkpoint).""" + runner = CliRunner() + result = runner.invoke(app, ["--help"]) + assert "hooks" in result.output + class TestBuildHelpOutput: def test_build_help_exit_code_zero(self) -> None: @@ -279,3 +318,147 @@ def test_cli_schema_lint_coexist(self) -> None: lint_result = runner.invoke(app, ["lint", "."]) assert lint_result.exit_code in (0, 1) + + +def test_cli_registers_history_group() -> None: + """The history group is registered on app and re-exported by the facade. + + Regression guard for the full registration chain: the group must be added + to the root ``app`` (help surface), expose all five subcommands, and be + re-exported through ``goga.commands.__all__`` — otherwise + ``from goga.commands import history`` breaks on some consumer paths even + though ``cli.py`` registered it. + """ + runner = CliRunner() + + root_help = runner.invoke(app, ["--help"]) + assert root_help.exit_code == 0 + assert "history" in root_help.output + + history_help = runner.invoke(app, ["history", "--help"]) + assert history_help.exit_code == 0 + for subcommand in ("list", "status", "path", "ensure", "prune"): + assert subcommand in history_help.output + + assert "history" in commands.__all__ + assert len(commands.__all__) == 16 + assert hasattr(commands, "history") + + +def test_cli_registers_hooks_command_and_invokes_it_through_the_root_group( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The hooks command is wired into the root app end-to-end. + + Registration chain plus one real dispatch: with the package enumeration + pinned to an empty environment the registry comes up empty, the command + prints nothing, and exits 0 — the deferred app-level invocation of the + Task 9 suite (the command was registered on nothing back there). + """ + monkeypatch.setattr( + "goga.hooks.tools.packages.packages_distributions", + lambda: {}, + ) + runner = CliRunner() + + hooks_help = runner.invoke(app, ["hooks", "--help"]) + assert hooks_help.exit_code == 0 + assert "--tool" in hooks_help.output + + result = runner.invoke(app, ["hooks"]) + assert result.exit_code == 0 + assert result.output == "" + + +def test_hooks_command_renders_a_real_registry_end_to_end( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The real chain — enumeration, identity, registration, view, render. + + A package named with underscores proves the composition end to end: the + tree shows the canonical hyphen identity ``my-tool``, the slice by that + form keeps the entry, and the underscore spelling is an unknown name that + keeps an empty entry — not an error. + """ + package = ModuleType("goga_tool_my_tool") + + def register_hooks(hooks: Any) -> None: + hooks.subscribe( + "statuses", + "register_statuses", + "published", + lambda context: context.register("published", "my/published.md", after="planned"), + ) + + package.register_hooks = register_hooks + monkeypatch.setitem(sys.modules, "goga_tool_my_tool", package) + monkeypatch.setattr( + "goga.hooks.tools.packages.packages_distributions", + lambda: {"goga_tool_my_tool": ["goga-tool-my-tool"]}, + ) + runner = CliRunner() + + tree = runner.invoke(app, ["hooks"]) + + assert tree.exit_code == 0 + assert tree.output == "my-tool\n statuses\n register_statuses published\n" + + sliced = runner.invoke(app, ["hooks", "-t", "my-tool"]) + assert sliced.exit_code == 0 + assert sliced.output == tree.output + + underscored = runner.invoke(app, ["hooks", "-t", "my_tool"]) + assert underscored.exit_code == 0 + assert underscored.output == "my_tool\n" + + +def test_commands_without_hooks_never_enumerate_packages() -> None: + """Commands that use no hooks never build the registry. + + ``--version``, ``lint --help``, and ``tool --help`` answer without ever + reading the installed-distributions mapping: the enumeration boundary of + the hooks platform stays untouched until a hook-consuming command + actually assembles the registry. The mock counts, so the assertion + covers all three invocations at once. + """ + runner = CliRunner() + + with mock.patch("goga.hooks.tools.packages.packages_distributions") as enumeration: + for args in (["--version"], ["lint", "--help"], ["tool", "--help"]): + result = runner.invoke(app, args) + + assert result.exit_code == 0, args + + enumeration.assert_not_called() + + +def test_facades_export_topics() -> None: + """Every facade of the feature exports its contract names. + + Design-doc scenario: ``topics`` resolves through ``goga.commands``; the + domain entries resolve through ``goga.topics``; the history embeddings + resolve through ``goga.history``; ``app`` registers the ``topics`` + command; the deleted single-status enum stays gone. + """ + import goga.commands + import goga.history + from goga import app as root_app + from goga.commands import topics as topics_group + from goga.history import ( + StatusScale, + assemble_status_scale, + resolve_history_root, + ) + from goga.topics import collect_topic_board, create_topic, switch_topic + + assert topics_group is not None + assert collect_topic_board is not None + assert switch_topic is not None + assert create_topic is not None + assert StatusScale is not None + assert assemble_status_scale is not None + assert resolve_history_root is not None + + assert "topics" in goga.commands.__all__ + assert any(command.name == "topics" for command in root_app.commands.values()) + assert "TopicStatus" not in goga.history.__all__ diff --git a/tests/topics/__init__.py b/tests/topics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/topics/conftest.py b/tests/topics/conftest.py new file mode 100644 index 00000000..12964615 --- /dev/null +++ b/tests/topics/conftest.py @@ -0,0 +1,28 @@ +"""Local fixtures of the topics domain tests.""" + +from __future__ import annotations + +import pytest +from goga.history.statuses import Stage, StatusScale + + +@pytest.fixture +def builtin_scale() -> StatusScale: + """Deterministic built-in scale — nine entries with the contract artifacts. + + The deepening order is the contract: empty, todo, defined, discovered, + backlog, designed, specified, planned, done. + """ + return StatusScale( + stages=[ + Stage(name="empty", filepath=""), + Stage(name="todo", filepath="todo.md"), + Stage(name="defined", filepath="prd.md"), + Stage(name="discovered", filepath="adr.md"), + Stage(name="backlog", filepath="task.md"), + Stage(name="designed", filepath="arch.md"), + Stage(name="specified", filepath="design.md"), + Stage(name="planned", filepath="plan.md"), + Stage(name="done", filepath="completed/plan.md"), + ] + ) diff --git a/tests/topics/editor/__init__.py b/tests/topics/editor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/topics/editor/test_entry.py b/tests/topics/editor/test_entry.py new file mode 100644 index 00000000..c75cf372 --- /dev/null +++ b/tests/topics/editor/test_entry.py @@ -0,0 +1,161 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/editor/CODEMANIFEST`` with ``location: entry.py``: + +- ``edit_text(initial=None)`` — the interactive multi-line text entry + session in the external editor + +The editor is mocked with a shell script exported as ``$EDITOR`` per +the ``editor`` practice and the TTY detection with a ``sys.stdin`` +stand-in — a real editor never launches in tests. The session's +temporary file lives in the system temp directory and is unlinked by +the editor facility, so the working tree of the test repository stays +untouched. +""" + +from __future__ import annotations + +import inspect +import sys +import typing +from pathlib import Path +from unittest import mock + +import click +import pytest +from goga.topics.editor import edit_text + +# --- Contract tests --- + + +class TestEntryContract: + def test_edit_text_is_importable_from_the_cell_facade(self) -> None: + """``edit_text`` lives on the cell facade as the only export.""" + import goga.topics.editor as cell + + assert cell.edit_text is edit_text + assert cell.__all__ == ["edit_text"] + + def test_declared_signature(self) -> None: + """The routine takes exactly the declared parameter.""" + assert list(inspect.signature(edit_text).parameters) == ["initial"] + + def test_parameter_is_positional_or_keyword_with_contract_hints(self) -> None: + """``initial`` binds positionally and by keyword, defaults to None.""" + parameters = inspect.signature(edit_text).parameters + parameter = parameters["initial"] + + assert parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + assert parameter.default is None + assert typing.get_type_hints(edit_text) == {"initial": str | None, "return": str | None} + + signature = inspect.signature(edit_text) + signature.bind() + signature.bind("text") + signature.bind(initial="text") + + +# --- Editor and terminal stand-ins --- + + +def _editor_script(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, body: str) -> None: + """Export ``$EDITOR`` as an executable shell script running ``body``.""" + editors = tmp_path / "editors" + editors.mkdir(exist_ok=True) + + script = editors / "editor-mock.sh" + script.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8") + script.chmod(0o755) + + monkeypatch.delenv("VISUAL", raising=False) + monkeypatch.setenv("EDITOR", str(script)) + + +def _tty(monkeypatch: pytest.MonkeyPatch, isatty: bool) -> None: + """Stand in for ``sys.stdin`` with a pinned TTY answer.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": isatty})) + + +def _repo(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """An empty repository working tree as the current directory.""" + repo = tmp_path / "repo" + repo.mkdir() + monkeypatch.chdir(repo) + + return repo + + +def _tree_of(repo: Path) -> list[str]: + """Every path of the working tree, relative and sorted.""" + return sorted(str(path.relative_to(repo)) for path in repo.rglob("*")) + + +# --- Logic tests --- + + +class TestEditText: + def test_edit_text_saves_text_as_entered(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """The saved text comes back verbatim — interior blank line kept, nothing trimmed.""" + _editor_script(monkeypatch, tmp_path, "printf 'Fix retries.\\n\\nIgnore the cap.\\n' > \"$1\"") + _tty(monkeypatch, isatty=True) + + assert edit_text() == "Fix retries.\n\nIgnore the cap.\n" + + def test_edit_text_unchanged_save_cancels(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """An editor that never writes cancels the entry — the working tree untouched.""" + repo = _repo(monkeypatch, tmp_path) + _editor_script(monkeypatch, tmp_path, "exit 0") + _tty(monkeypatch, isatty=True) + + assert edit_text(initial="Old text.\n") is None + assert _tree_of(repo) == [] + + @pytest.mark.parametrize( + "editor_command", + [ + "exit 0", + "printf 'Old text.\\n' > \"$1\"", + ], + ) + def test_edit_text_initial_without_newline_normalized( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, editor_command: str + ) -> None: + """A prefill without a trailing newline is normalized before the equality check. + + Both arms leave the caller with no change: an editor that never + writes, and one that writes the normalized prefill back verbatim. + """ + _tty(monkeypatch, isatty=True) + + _editor_script(monkeypatch, tmp_path, editor_command) + + assert edit_text(initial="Old text.") is None + + def test_edit_text_non_tty_clean_error(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """No terminal — a clean error before the hint and the editor launch.""" + marker = tmp_path / "editors" / "launched.marker" + marker.parent.mkdir(exist_ok=True) + _editor_script(monkeypatch, tmp_path, f"printf launched > '{marker}'") + _tty(monkeypatch, isatty=False) + + with pytest.raises(click.ClickException, match="interactive"): + edit_text("text") + + assert not marker.exists() + + def test_edit_text_failed_editor_clean_error(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """A non-zero editor exit is a clean error — no working-tree file touched.""" + repo = _repo(monkeypatch, tmp_path) + _editor_script(monkeypatch, tmp_path, "exit 3") + _tty(monkeypatch, isatty=True) + + with pytest.raises(click.ClickException, match="Editing failed"): + edit_text("text") + + assert _tree_of(repo) == [] + + def test_edit_text_blank_only_save_cancels(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """A saved file of only blank lines cancels the entry.""" + _editor_script(monkeypatch, tmp_path, "printf '\\n\\n \\n' > \"$1\"") + _tty(monkeypatch, isatty=True) + + assert edit_text() is None diff --git a/tests/topics/git/__init__.py b/tests/topics/git/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/topics/git/test_publish.py b/tests/topics/git/test_publish.py new file mode 100644 index 00000000..82e232c3 --- /dev/null +++ b/tests/topics/git/test_publish.py @@ -0,0 +1,460 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/git/CODEMANIFEST`` with ``location: publish.py``: + +- ``resolve_ref_commit(ref)`` — resolve a revision string into the commit + it names +- ``commit_file_on_base(base, path, content, message)`` — build one commit + that adds a single file on top of a parent commit, without touching the + working copy +- ``create_branch_at_commit(branch_name, commit)`` — create a branch at a + commit without switching to it +- ``delete_local_branch(branch_name)`` — delete a local branch +- ``delete_remote_branch(branch_name)`` — delete a branch on the origin + remote +- ``push_branch(branch_name)`` — publish the branch to origin with upstream + binding +- ``origin_configured()`` — the strict origin probe + +The subprocess call is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched; the quarantined +index is the one real filesystem object (``tempfile.mkstemp`` inside the +``.git`` directory the test provides). +""" + +from __future__ import annotations + +import inspect +import subprocess +import typing +from pathlib import Path +from unittest import mock + +import pytest +from goga.topics.git import ( + commit_file_on_base, + create_branch_at_commit, + delete_local_branch, + delete_remote_branch, + origin_configured, + push_branch, + resolve_ref_commit, +) + +_TODO_PATH = ".goga/history/2026/feature-foo/todo.md" +_TODO_CONTENT = "Payment retry\n" +_TODO_MESSAGE = "goga: create topic feature-foo" + + +def _git_answer(stdout: str = "") -> subprocess.CompletedProcess[str]: + """A successful git invocation answering ``stdout``.""" + return subprocess.CompletedProcess(args=["git"], returncode=0, stdout=stdout, stderr="") + + +def _commands_of(run: mock.Mock) -> list[list[str]]: + """The argv list of every invocation the mock received.""" + return [call.args[0] for call in run.call_args_list] + + +# --- Contract tests --- + + +class TestPublishContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """All six publish routines live on the cell facade.""" + import goga.topics.git as cell + + assert cell.resolve_ref_commit is resolve_ref_commit + assert cell.commit_file_on_base is commit_file_on_base + assert cell.create_branch_at_commit is create_branch_at_commit + assert cell.delete_local_branch is delete_local_branch + assert cell.delete_remote_branch is delete_remote_branch + assert cell.push_branch is push_branch + assert cell.origin_configured is origin_configured + for name in ( + "resolve_ref_commit", + "commit_file_on_base", + "create_branch_at_commit", + "delete_local_branch", + "delete_remote_branch", + "push_branch", + "origin_configured", + ): + assert name in cell.__all__ + + def test_declared_signatures(self) -> None: + """The routines take exactly the declared parameters.""" + assert list(inspect.signature(resolve_ref_commit).parameters) == ["ref"] + assert list(inspect.signature(commit_file_on_base).parameters) == ["base", "path", "content", "message"] + assert list(inspect.signature(create_branch_at_commit).parameters) == ["branch_name", "commit"] + assert list(inspect.signature(delete_local_branch).parameters) == ["branch_name"] + assert list(inspect.signature(delete_remote_branch).parameters) == ["branch_name"] + assert list(inspect.signature(push_branch).parameters) == ["branch_name"] + assert list(inspect.signature(origin_configured).parameters) == [] + + def test_parameters_are_positional_or_keyword_with_contract_hints(self) -> None: + """No extras, no defaults, and the declared type hints.""" + hints = { + resolve_ref_commit: {"ref": str, "return": str}, + commit_file_on_base: {"base": str, "path": str, "content": str, "message": str, "return": str}, + create_branch_at_commit: {"branch_name": str, "commit": str, "return": type(None)}, + delete_local_branch: {"branch_name": str, "return": type(None)}, + delete_remote_branch: {"branch_name": str, "return": type(None)}, + push_branch: {"branch_name": str, "return": type(None)}, + origin_configured: {"return": bool}, + } + + for routine, declared in hints.items(): + parameters = inspect.signature(routine).parameters + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in parameters.values() + ), routine + assert all(parameter.default is inspect.Parameter.empty for parameter in parameters.values()), routine + assert typing.get_type_hints(routine) == declared, routine + + def test_delete_remote_branch_callable_with_name(self) -> None: + """The routine binds as ``delete_remote_branch("name")`` and returns None.""" + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.publish.subprocess.run", run): + result = delete_remote_branch("name") + + assert result is None + + +# --- Logic tests --- + + +class TestResolveRefCommit: + def test_resolve_ref_commit_returns_peeled_commit(self) -> None: + """``^{commit}`` peels annotated tags — the hash is a commit hash.""" + run = mock.Mock(return_value=_git_answer("1a2b3c4d5e6f7890\n")) + + with mock.patch("goga.topics.git.publish.subprocess.run", run): + commit = resolve_ref_commit("origin/main") + + assert commit == "1a2b3c4d5e6f7890" + assert run.call_args.args[0] == ["git", "rev-parse", "--verify", "origin/main^{commit}"] + + def test_resolve_ref_commit_propagates_git_failure(self) -> None: + """An unresolvable revision raises raw — the cell never wraps.""" + failure = subprocess.CalledProcessError(128, ["git", "rev-parse"], stderr="fatal: Needed a single revision") + + with ( + mock.patch("goga.topics.git.publish.subprocess.run", side_effect=failure), + pytest.raises(subprocess.CalledProcessError), + ): + resolve_ref_commit("origin/absent") + + +class TestCommitFileOnBase: + def test_commit_file_on_base_builds_commit_through_quarantined_index(self, tmp_path: Path) -> None: + """The six-step chain — one quarantined index, nothing left behind.""" + git_dir = tmp_path / ".git" + git_dir.mkdir() + + def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + stdout = { + ("rev-parse", "--git-dir"): str(git_dir), + ("hash-object", "-w", "--stdin"): "<blob>", + ("write-tree",): "<tree>", + ("commit-tree", "<tree>", "-p", "<base>", "-m", _TODO_MESSAGE): "<commit>", + }.get(tuple(command[1:]), "") + return subprocess.CompletedProcess(args=command, returncode=0, stdout=stdout, stderr="") + + run = mock.Mock(side_effect=answer_by_argv) + + with mock.patch("goga.topics.git.publish.subprocess.run", run): + commit = commit_file_on_base("<base>", _TODO_PATH, _TODO_CONTENT, _TODO_MESSAGE) + + assert commit == "<commit>" + assert _commands_of(run) == [ + ["git", "rev-parse", "--git-dir"], + ["git", "read-tree", "<base>"], + ["git", "hash-object", "-w", "--stdin"], + ["git", "update-index", "--add", "--cacheinfo", f"100644,<blob>,{_TODO_PATH}"], + ["git", "write-tree"], + ["git", "commit-tree", "<tree>", "-p", "<base>", "-m", _TODO_MESSAGE], + ] + + quarantined = {"read-tree", "update-index", "write-tree"} + + for call in run.call_args_list: + env = call.kwargs["env"] + assert env["GIT_TERMINAL_PROMPT"] == "0" + if call.args[0][1] in quarantined: + assert "GIT_INDEX_FILE" in env + else: + assert "GIT_INDEX_FILE" not in env + assert call.kwargs["encoding"] == "utf-8" + + index = Path(run.call_args_list[1].kwargs["env"]["GIT_INDEX_FILE"]) + assert index.parent == git_dir + assert index.name.startswith("goga-publish-index-") + assert not index.exists() + + assert run.call_args_list[2].kwargs["input"] == _TODO_CONTENT + + def test_commit_file_on_base_removes_temporary_index_on_failure(self, tmp_path: Path) -> None: + """A failed chain still leaves no index behind — the ``finally`` unlink.""" + git_dir = tmp_path / ".git" + git_dir.mkdir() + + def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + argv = tuple(command[1:]) + if argv == ("write-tree",): + raise subprocess.CalledProcessError(128, command, stderr="fatal: unable to write tree") + stdout = str(git_dir) if argv == ("rev-parse", "--git-dir") else "" + return subprocess.CompletedProcess(args=command, returncode=0, stdout=stdout, stderr="") + + run = mock.Mock(side_effect=answer_by_argv) + + with ( + mock.patch("goga.topics.git.publish.subprocess.run", run), + pytest.raises(subprocess.CalledProcessError), + ): + commit_file_on_base("<base>", _TODO_PATH, _TODO_CONTENT, _TODO_MESSAGE) + + index = Path(run.call_args_list[1].kwargs["env"]["GIT_INDEX_FILE"]) + assert index.parent == git_dir + assert not index.exists() + + def test_commit_file_on_base_empty_message_never_waits_for_stdin(self, tmp_path: Path) -> None: + """An empty template completes instead of waiting on the caller's stdin. + + ``commit-tree -m ""`` counts the empty ``-m`` as "no message + supplied" and falls back to reading the message from stdin. Without + the devnull redirect the invocation inherited the caller's stdin — + a terminal or an open pipe under a harness, neither of which ever + reaches EOF — and the publish cycle hung forever with no output and + no error. The invocations that legitimately need stdin pass + ``input`` explicitly and keep their pipe. + """ + git_dir = tmp_path / ".git" + git_dir.mkdir() + + def answer_by_argv(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + stdout = { + ("rev-parse", "--git-dir"): str(git_dir), + ("hash-object", "-w", "--stdin"): "<blob>", + ("write-tree",): "<tree>", + ("commit-tree", "<tree>", "-p", "<base>", "-m", ""): "<commit>", + }.get(tuple(command[1:]), "") + return subprocess.CompletedProcess(args=command, returncode=0, stdout=stdout, stderr="") + + run = mock.Mock(side_effect=answer_by_argv) + + with mock.patch("goga.topics.git.publish.subprocess.run", run): + commit = commit_file_on_base("<base>", _TODO_PATH, _TODO_CONTENT, "") + + assert commit == "<commit>" + for call in run.call_args_list: + if call.kwargs.get("input") is None: + assert call.kwargs["stdin"] == subprocess.DEVNULL + else: + # The explicit ``input`` routes the invocation through a + # pipe — a second stdin would make ``subprocess.run`` raise. + assert call.kwargs["stdin"] is None + # The empty message reached git verbatim — no error, no substitution. + assert run.call_args_list[-1].args[0][-1] == "" + + +class TestBranchAndPushMutations: + def test_create_branch_at_commit_creates_ref_without_switch(self) -> None: + """The plant pins ``refs/heads`` and leaves the working copy alone.""" + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.publish.subprocess.run", run): + result = create_branch_at_commit("Feature/Foo_Bar", "<commit>") + + assert result is None + assert run.call_count == 1 + assert run.call_args.args[0] == ["git", "update-ref", "--stdin", "-z"] + assert run.call_args.kwargs["input"] == "create refs/heads/Feature/Foo_Bar\0<commit>\0" + assert run.call_args.kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0" + + def test_create_branch_at_commit_stream_cannot_move_an_existing_ref(self) -> None: + """The plant is create-only — a plain ``update-ref <ref> <commit>`` + would move an existing ref, and the occupancy oracle can miss one + (git lengthens the display name of ``refs/heads/v1`` to ``heads/v1`` + when a tag of the same name exists; a concurrent writer can plant the + name in between). The moved branch would then be deleted by the + caller's rollback — real work lost behind a push error. The ``create`` + stream refuses an existing ref before anything is mutated. + """ + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.publish.subprocess.run", run): + create_branch_at_commit("--mirror", "<commit>") + + stream = run.call_args.kwargs["input"] + assert stream == "create refs/heads/--mirror\0<commit>\0" + # A dash-leading name stays a ref in the stream — never an option. + assert not stream.split("\0")[0].split(" ", 1)[1].startswith("-") + + def test_create_branch_at_commit_stream_cannot_split_a_second_command(self) -> None: + """A newline inside the name stays one refname — never a second command. + + The line-oriented stream splits on ``LF``, so a machine-generated + name carrying a newline would open a second command of the same + transaction — ``create refs/heads/x <oid>`` followed by ``update + refs/heads/main <oid>`` silently moves the user's branch. The NUL + delimiters keep the verbatim name one token, so git's own refname + validation owns it instead of the stream parser. + """ + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.publish.subprocess.run", run): + create_branch_at_commit("evil <oid>\nupdate refs/heads/main", "<commit>") + + stream = run.call_args.kwargs["input"] + # The whole name sits inside the single NUL-delimited refname slot. + tokens = stream.split("\0") + assert tokens[0] == "create refs/heads/evil <oid>\nupdate refs/heads/main" + assert tokens[1] == "<commit>" + # No LF ever terminates a command — only the two NULs delimit fields. + assert stream.count("\n") == 1 + + def test_delete_local_branch_deletes_ref(self) -> None: + """The rollback addresses the same ``refs/heads`` ref the plant created.""" + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.publish.subprocess.run", run): + delete_local_branch("Feature/Foo_Bar") + + assert run.call_args.args[0] == ["git", "update-ref", "-d", "refs/heads/Feature/Foo_Bar"] + + def test_push_branch_pushes_with_upstream_binding(self) -> None: + """Exactly the named branch, ``-u`` present, origin hardcoded.""" + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.publish.subprocess.run", run): + push_branch("Feature/Foo_Bar") + + assert run.call_count == 1 + assert run.call_args.args[0] == [ + "git", + "push", + "--no-follow-tags", + "-u", + "origin", + "refs/heads/Feature/Foo_Bar:refs/heads/Feature/Foo_Bar", + ] + + def test_push_branch_does_not_follow_tags(self) -> None: + """``--no-follow-tags`` holds the no-tags line under user config. + + The refspec alone names exactly one branch, but git's + ``push.followTags`` config pushes local-only annotated tags sitting + on the pushed commits alongside the refspec — the contract forbids + publishing anything but the named branch, so the explicit negation + overrides the user's config. + """ + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.publish.subprocess.run", run): + push_branch("Feature/Foo_Bar") + + assert "--no-follow-tags" in run.call_args.args[0] + + def test_push_branch_refspec_cannot_be_parsed_as_an_option(self) -> None: + """A dash-leading branch name stays a refspec — never a push option. + + Git accepts ``refs/heads/--mirror`` and the plant creates names + verbatim, so a bare-name argv would hand git ``push -u origin + --mirror``: git would then sync and prune every remote ref while + reporting success. The ``refs/heads/...:refs/heads/...`` form starts + with ``r`` and can only ever name exactly the one branch. + """ + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.publish.subprocess.run", run): + push_branch("--mirror") + + refspec = run.call_args.args[0][-1] + assert refspec == "refs/heads/--mirror:refs/heads/--mirror" + assert not refspec.startswith("-") + + +class TestDeleteRemoteBranch: + def test_delete_remote_branch_pushes_full_refspec(self) -> None: + """One deletion push addressing the branch through its full ref. + + A short name that starts with a dash would be parsed as a push + option — after ``--delete`` a bare ``--mirror`` does not name a + branch anymore. The ``refs/heads/...`` refspec can never start + with a dash, so exactly the named branch goes. + """ + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.publish.subprocess.run", run): + delete_remote_branch("feature-foo") + + assert run.call_count == 1 + assert run.call_args.args[0] == [ + "git", + "push", + "origin", + "--delete", + "refs/heads/feature-foo", + ] + + def test_delete_remote_branch_refspec_cannot_be_parsed_as_an_option(self) -> None: + """A dash-leading branch name stays a refspec — never a push option. + + Git accepts ``refs/heads/--mirror`` and the plant creates names + verbatim, so a bare-name argv would hand git ``push origin + --delete --mirror``: after ``--delete`` the bare ``--mirror`` no + longer names a branch, and ``--repo`` or ``--all`` would act at + all. The ``refs/heads/...`` refspec can never start with a dash. + """ + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.publish.subprocess.run", run): + delete_remote_branch("--mirror") + + refspec = run.call_args.args[0][-1] + assert refspec == "refs/heads/--mirror" + assert not refspec.startswith("-") + + def test_delete_remote_branch_git_failure_propagates_raw(self) -> None: + """A rejected deletion push raises raw — the cell never wraps. + + The caller (``delete_topics``) owns the failure policy: it restores + the local branch at the captured commit and renders one clean + error, so a wrap here would bury the git reason under a second + exception layer. + """ + failure = subprocess.CalledProcessError(1, ["git", "push", "origin"], stderr=b"deny") + + with ( + mock.patch("goga.topics.git.publish.subprocess.run", side_effect=failure), + pytest.raises(subprocess.CalledProcessError) as raised, + ): + delete_remote_branch("feature-foo") + + assert raised.value is failure + + +class TestOriginConfigured: + def test_origin_configured_true_when_configured(self) -> None: + """A readable origin remote URL reads True.""" + run = mock.Mock(return_value=_git_answer("git@github.com:o/r.git\n")) + + with mock.patch("goga.topics.git.publish.subprocess.run", run): + configured = origin_configured() + + assert configured is True + assert run.call_args.args[0] == ["git", "remote", "get-url", "origin"] + + @pytest.mark.parametrize( + "failure", + [ + subprocess.CalledProcessError(2, ["git", "remote", "get-url", "origin"]), + FileNotFoundError("git"), + ], + ids=["no-origin-remote", "no-git-binary"], + ) + def test_origin_configured_false_without_origin(self, failure: Exception) -> None: + """The probe never raises — both failure shapes read False.""" + with mock.patch("goga.topics.git.publish.subprocess.run", side_effect=failure): + assert origin_configured() is False diff --git a/tests/topics/git/test_refs.py b/tests/topics/git/test_refs.py new file mode 100644 index 00000000..954b4ab4 --- /dev/null +++ b/tests/topics/git/test_refs.py @@ -0,0 +1,137 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/git/CODEMANIFEST`` with ``location: refs.py``: + +- ``BranchRef(name, remote)`` — one branch ref of the repository inventory +- ``list_branch_refs()`` — the read-only enumerator merging local branches + and remote-tracking refs into one sorted inventory + +The subprocess call is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched. +""" + +from __future__ import annotations + +import dataclasses +import os +import subprocess +from collections.abc import Callable +from unittest import mock + +import pytest +from goga.topics.git import BranchRef, list_branch_refs + + +def _git_answer(stdout: str) -> subprocess.CompletedProcess[str]: + """A successful ``for-each-ref`` invocation answering ``stdout``.""" + return subprocess.CompletedProcess(args=["git"], returncode=0, stdout=stdout, stderr="") + + +def _answering_run(heads: str = "", remotes: str = "") -> Callable[..., subprocess.CompletedProcess[str]]: + """A ``subprocess.run`` mock answering by the requested ref prefix.""" + + def run(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + outputs = {"refs/heads": heads, "refs/remotes": remotes} + return _git_answer(outputs[command[-1]]) + + return run + + +# --- Contract tests --- + + +class TestRefsContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """``BranchRef`` and ``list_branch_refs`` live on the cell facade.""" + import goga.topics.git as cell + + assert cell.BranchRef is BranchRef + assert cell.list_branch_refs is list_branch_refs + assert "BranchRef" in cell.__all__ + assert "list_branch_refs" in cell.__all__ + + def test_branch_ref_is_a_frozen_kw_only_dataclass(self) -> None: + """``BranchRef(name=..., remote=...)`` — frozen, keyword-only.""" + ref = BranchRef(name="feat/a", remote=False) + + assert ref.name == "feat/a" + assert ref.remote is False + + with pytest.raises(TypeError): + BranchRef("feat/a", False) # type: ignore[misc] + with pytest.raises(dataclasses.FrozenInstanceError): + ref.name = "renamed" # type: ignore[misc] + + def test_branch_ref_declares_name_and_remote_only(self) -> None: + """The record carries exactly the two declared fields.""" + fields = {field.name for field in dataclasses.fields(BranchRef)} + assert fields == {"name", "remote"} + + def test_list_branch_refs_takes_no_arguments_and_returns_refs(self) -> None: + """``list_branch_refs() -> list[BranchRef]`` — no parameters.""" + with mock.patch("goga.topics.git.refs.subprocess.run", side_effect=_answering_run()): + refs = list_branch_refs() + + assert isinstance(refs, list) + assert all(isinstance(ref, BranchRef) for ref in refs) + + def test_git_invocations_follow_the_git_practice(self) -> None: + """Two ``for-each-ref`` calls — check/capture/text and a muted prompt.""" + run = mock.Mock(side_effect=_answering_run()) + + with mock.patch("goga.topics.git.refs.subprocess.run", run): + list_branch_refs() + + assert run.call_count == 2 + for call in run.call_args_list: + command = call.args[0] + assert command[:2] == ["git", "for-each-ref"] + assert "--format=%(refname:short)" in command + assert call.kwargs["check"] is True + assert call.kwargs["capture_output"] is True + assert call.kwargs["text"] is True + assert call.kwargs["env"] == {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + prefixes = [call.args[0][-1] for call in run.call_args_list] + assert prefixes == ["refs/heads", "refs/remotes"] + + +# --- Logic tests --- + + +class TestListBranchRefs: + def test_list_branch_refs_merges_and_sorts(self) -> None: + """Local and remote refs merge sorted by display; ``*/HEAD`` dropped.""" + run = mock.Mock( + side_effect=_answering_run( + heads="main\nfeat/a\n", + remotes="origin/HEAD\norigin/feat/a\norigin/feat/b\n", + ) + ) + + with mock.patch("goga.topics.git.refs.subprocess.run", run): + refs = list_branch_refs() + + assert [ref.name for ref in refs] == [ + "feat/a", + "main", + "origin/feat/a", + "origin/feat/b", + ] + assert refs[0].remote is False + assert refs[1].remote is False + assert refs[2].remote is True + assert refs[3].remote is True + # No deduplication — the local branch and its remote twin are both kept. + assert "feat/a" in [ref.name for ref in refs] + assert "origin/feat/a" in [ref.name for ref in refs] + # The origin/HEAD symref is not a branch — dropped. + assert "origin/HEAD" not in [ref.name for ref in refs] + + def test_list_branch_refs_empty_repository(self) -> None: + """An empty inventory is the norm, answered by exactly two calls.""" + run = mock.Mock(side_effect=_answering_run()) + + with mock.patch("goga.topics.git.refs.subprocess.run", run): + refs = list_branch_refs() + + assert refs == [] + assert run.call_count == 2 diff --git a/tests/topics/git/test_switch.py b/tests/topics/git/test_switch.py new file mode 100644 index 00000000..63c394ec --- /dev/null +++ b/tests/topics/git/test_switch.py @@ -0,0 +1,152 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/git/CODEMANIFEST`` with ``location: switch.py``: + +- ``checkout_local_branch(branch)`` — switch the working copy to an + existing local branch +- ``create_branch_from_remote_tracking(ref)`` — create a local branch + from a remote-tracking ref and switch to it +- ``create_and_switch_branch(branch_name)`` — create a branch with the + name exactly as entered and switch to it +- ``is_working_tree_clean()`` — the read-only cleanliness probe + +The subprocess call is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched. +""" + +from __future__ import annotations + +import inspect +import os +import subprocess +from unittest import mock + +from goga.topics.git import ( + BranchRef, + checkout_local_branch, + create_and_switch_branch, + create_branch_from_remote_tracking, + is_working_tree_clean, +) + + +def _git_answer(stdout: str = "") -> subprocess.CompletedProcess[str]: + """A successful git invocation answering ``stdout``.""" + return subprocess.CompletedProcess(args=["git"], returncode=0, stdout=stdout, stderr="") + + +def _commands_of(run: mock.Mock) -> list[list[str]]: + """The argv list of every invocation the mock received.""" + return [call.args[0] for call in run.call_args_list] + + +# --- Contract tests --- + + +class TestSwitchContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """All four switch routines live on the cell facade.""" + import goga.topics.git as cell + + assert cell.checkout_local_branch is checkout_local_branch + assert cell.create_branch_from_remote_tracking is create_branch_from_remote_tracking + assert cell.create_and_switch_branch is create_and_switch_branch + assert cell.is_working_tree_clean is is_working_tree_clean + for name in ( + "checkout_local_branch", + "create_branch_from_remote_tracking", + "create_and_switch_branch", + "is_working_tree_clean", + ): + assert name in cell.__all__ + + def test_declared_signatures(self) -> None: + """The routines take exactly the declared parameters.""" + assert list(inspect.signature(checkout_local_branch).parameters) == ["branch"] + assert list(inspect.signature(create_branch_from_remote_tracking).parameters) == ["ref"] + assert list(inspect.signature(create_and_switch_branch).parameters) == ["branch_name"] + assert list(inspect.signature(is_working_tree_clean).parameters) == [] + + def test_mutations_are_git_switch_invocations(self) -> None: + """The three mutations are bounded host-side ``git switch`` actions.""" + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.switch.subprocess.run", run): + checkout_local_branch("feat/a") + create_branch_from_remote_tracking(BranchRef(name="origin/feat/b", remote=True)) + create_and_switch_branch("Feature/Foo_Bar") + + assert _commands_of(run) == [ + ["git", "switch", "feat/a"], + ["git", "switch", "-c", "feat/b", "origin/feat/b"], + ["git", "switch", "-c", "Feature/Foo_Bar"], + ] + + def test_cleanliness_probe_is_a_porcelain_invocation(self) -> None: + """The probe reads the working tree state — nothing else.""" + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.switch.subprocess.run", run): + is_working_tree_clean() + + assert _commands_of(run) == [["git", "status", "--porcelain"]] + + def test_git_invocations_follow_the_git_practice(self) -> None: + """Every call — check/capture/text and a muted prompt.""" + calls = [ + (checkout_local_branch, ("feat/a",)), + (create_branch_from_remote_tracking, (BranchRef(name="origin/feat/b", remote=True),)), + (create_and_switch_branch, ("Feature/Foo_Bar",)), + (is_working_tree_clean, ()), + ] + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.switch.subprocess.run", run): + for routine, args in calls: + routine(*args) + + assert run.call_count == len(calls) + for call in run.call_args_list: + kwargs = call.kwargs + assert kwargs["check"] is True + assert kwargs["capture_output"] is True + assert kwargs["text"] is True + assert kwargs["env"] == {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + + +# --- Logic tests --- + + +class TestSwitchBehaviour: + def test_is_working_tree_clean_boolean(self) -> None: + """An empty porcelain report is clean; any entry is dirty.""" + with mock.patch("goga.topics.git.switch.subprocess.run", return_value=_git_answer("")): + assert is_working_tree_clean() is True + with mock.patch("goga.topics.git.switch.subprocess.run", return_value=_git_answer(" M x.py\n")): + assert is_working_tree_clean() is False + + def test_create_branch_from_remote_tracking_takes_the_short_name(self) -> None: + """The local branch is named after the part past the first slash.""" + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.switch.subprocess.run", run): + create_branch_from_remote_tracking(BranchRef(name="origin/feat/b", remote=True)) + + assert _commands_of(run) == [["git", "switch", "-c", "feat/b", "origin/feat/b"]] + + def test_create_and_switch_branch_takes_the_name_verbatim(self) -> None: + """No normalization, no suffixing — the name goes to git as entered.""" + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.switch.subprocess.run", run): + create_and_switch_branch("Feature/Foo_Bar") + + assert _commands_of(run) == [["git", "switch", "-c", "Feature/Foo_Bar"]] + + def test_checkout_local_branch_switches_without_creating(self) -> None: + """A plain checkout — no ``-c``, the branch must already exist.""" + run = mock.Mock(return_value=_git_answer()) + + with mock.patch("goga.topics.git.switch.subprocess.run", run): + checkout_local_branch("feat/a") + + assert _commands_of(run) == [["git", "switch", "feat/a"]] diff --git a/tests/topics/git/test_trees.py b/tests/topics/git/test_trees.py new file mode 100644 index 00000000..892f0ebf --- /dev/null +++ b/tests/topics/git/test_trees.py @@ -0,0 +1,228 @@ +"""Contract and logic tests for the entity declared in +``goga/topics/git/CODEMANIFEST`` with ``location: trees.py``: + +- ``read_ref_tree_paths(ref, prefix)`` — the read-only file listing of one + ref tree under a path prefix, without checkout, worktree, or temp + directories +- ``read_ref_file(ref, path)`` — the read-only content of one file of a + ref tree, without checkout, worktree, or temp directories + +The subprocess call is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched. +""" + +from __future__ import annotations + +import inspect +import os +import subprocess +import typing +from unittest import mock + +from goga.topics.git import read_ref_file, read_ref_tree_paths + + +def _git_answer(stdout: str) -> subprocess.CompletedProcess[str]: + """A successful git invocation answering ``stdout``.""" + return subprocess.CompletedProcess(args=["git"], returncode=0, stdout=stdout, stderr="") + + +# --- Contract tests --- + + +class TestTreesContract: + def test_entity_is_importable_from_the_cell_facade(self) -> None: + """``read_ref_tree_paths`` lives on the cell facade.""" + import goga.topics.git as cell + + assert cell.read_ref_tree_paths is read_ref_tree_paths + assert "read_ref_tree_paths" in cell.__all__ + + def test_signature_takes_ref_and_prefix_and_returns_paths(self) -> None: + """``read_ref_tree_paths(ref, prefix) -> list[str]``.""" + with ( + mock.patch( + "goga.topics.git.trees.subprocess.run", + return_value=_git_answer(".goga/history/2026/feat-a/plan.md\n"), + ) as run, + ): + paths = read_ref_tree_paths("feat-a", ".goga/history/") + + assert isinstance(paths, list) + assert all(isinstance(path, str) for path in paths) + assert run.call_count == 1 + + def test_git_invocation_follows_the_git_practice(self) -> None: + """One ``ls-tree -r --name-only`` call — check/capture/text, muted prompt.""" + run = mock.Mock(return_value=_git_answer("")) + + with mock.patch("goga.topics.git.trees.subprocess.run", run): + read_ref_tree_paths("feat-a", ".goga/history/") + + assert run.call_count == 1 + command = run.call_args.args[0] + assert command == [ + "git", + "ls-tree", + "-r", + "--name-only", + "--full-name", + "--", + "feat-a", + ":/.goga/history/", + ] + kwargs = run.call_args.kwargs + assert kwargs["check"] is True + assert kwargs["capture_output"] is True + assert kwargs["text"] is True + assert kwargs["env"] == {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + + def test_git_invocation_separates_a_dash_leading_ref(self) -> None: + """The ``--`` separator precedes the ref, never only the pathspec. + + A display name that starts with a dash (git accepts + ``refs/heads/--mirror``, and the publish path plants names verbatim) + would otherwise be parsed as an ls-tree option and fail every + inventory read of the repository with ``unknown option``. + """ + run = mock.Mock(return_value=_git_answer("")) + + with mock.patch("goga.topics.git.trees.subprocess.run", run): + read_ref_tree_paths("--mirror", ".goga/history/") + + assert run.call_args.args[0] == [ + "git", + "ls-tree", + "-r", + "--name-only", + "--full-name", + "--", + "--mirror", + ":/.goga/history/", + ] + + def test_git_invocation_anchors_the_read_at_the_repository_root(self) -> None: + """``--full-name`` and the ``:/`` pathspec magic pin both sides root. + + The contract anchors the prefix and the reported paths at the + repository root, but git resolves a bare pathspec against the working + directory and reports paths relative to it — a caller inside a + subdirectory would read nothing while the publication write + (``--cacheinfo``) lands at the root regardless, and the occupancy + oracle would miss the very conflict it exists to catch. The magic + prefix is handed to git only; the returned paths are matched against + the caller's prefix unchanged. + """ + run = mock.Mock(return_value=_git_answer(".goga/history/2026/feat-a/plan.md\n")) + + with mock.patch("goga.topics.git.trees.subprocess.run", run): + paths = read_ref_tree_paths("feat-a", ".goga/history/2026/feat-a/") + + command = run.call_args.args[0] + assert "--full-name" in command + assert command[-1] == ":/.goga/history/2026/feat-a/" + assert paths == [".goga/history/2026/feat-a/plan.md"] + + def test_file_entity_is_importable_from_the_cell_facade(self) -> None: + """``read_ref_file`` lives on the fifteen-name cell facade.""" + import goga.topics.git as cell + + assert cell.read_ref_file is read_ref_file + assert "read_ref_file" in cell.__all__ + assert cell.__all__ == sorted(cell.__all__) + assert len(cell.__all__) == 15 + + def test_file_signature_takes_ref_and_path_and_returns_optional_str(self) -> None: + """``read_ref_file(ref: str, path: str) -> str | None``.""" + signature = inspect.signature(read_ref_file) + + assert list(signature.parameters) == ["ref", "path"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + + hints = typing.get_type_hints(read_ref_file) + assert hints == {"ref": str, "path": str, "return": str | None} + + +# --- Logic tests --- + + +class TestReadRefTreePaths: + def test_read_ref_tree_paths_filters_prefix(self) -> None: + """Only paths under the prefix survive — one invocation per ref.""" + run = mock.Mock(return_value=_git_answer(".goga/history/2026/feat-a/plan.md\nREADME.md\n")) + + with mock.patch("goga.topics.git.trees.subprocess.run", run): + result = read_ref_tree_paths("feat-a", ".goga/history/") + + assert result == [".goga/history/2026/feat-a/plan.md"] + assert run.call_count == 1 + + def test_read_ref_tree_paths_no_matches_empty(self) -> None: + """A ref or prefix without matches yields an empty list — not an error.""" + with mock.patch("goga.topics.git.trees.subprocess.run", return_value=_git_answer("")): + assert read_ref_tree_paths("feat-a", ".goga/history/") == [] + + def test_read_ref_tree_paths_keeps_git_order(self) -> None: + """Paths return in the order git reports them.""" + stdout = ".goga/history/2026/feat-a/plan.md\n.goga/history/2026/feat-a/prd.md\n" + + with mock.patch("goga.topics.git.trees.subprocess.run", return_value=_git_answer(stdout)): + result = read_ref_tree_paths("feat-a", ".goga/history/") + + assert result == [".goga/history/2026/feat-a/plan.md", ".goga/history/2026/feat-a/prd.md"] + + +class TestReadRefFile: + def test_read_ref_file_returns_content_as_is(self) -> None: + """The content returns as-is — one ``git show``, UTF-8, muted prompt.""" + run = mock.Mock(return_value=_git_answer("Payment retry\n")) + + with mock.patch("goga.topics.git.trees.subprocess.run", run): + content = read_ref_file("feat/a", ".goga/history/2026/feat-a/todo.md") + + assert content == "Payment retry\n" + assert run.call_count == 1 + command = run.call_args.args[0] + assert command == ["git", "show", "feat/a:.goga/history/2026/feat-a/todo.md"] + kwargs = run.call_args.kwargs + assert kwargs["check"] is True + assert kwargs["capture_output"] is True + assert kwargs["text"] is True + assert kwargs["encoding"] == "utf-8" + assert kwargs["env"] == {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + + def test_read_ref_file_absent_file_returns_none(self) -> None: + """An absent file at the ref yields None — not an error.""" + failure = subprocess.CalledProcessError(1, ["git", "show"], stderr="fatal: path 'x' does not exist") + + with mock.patch("goga.topics.git.trees.subprocess.run", side_effect=failure): + content = read_ref_file("feat/a", ".goga/history/2026/feat-a/absent.md") + + assert content is None + + def test_read_ref_file_decodes_invalid_bytes_with_replacement(self) -> None: + """A hand-edited non-UTF-8 file never crashes the read. + + The invocation decodes with the replacement policy — the content is + display data (the todo column), so an undecodable byte degrades to + U+FFFD instead of raising through the board's clean-error boundary. + """ + run = mock.Mock(return_value=_git_answer("Pay�ment\n")) + + with mock.patch("goga.topics.git.trees.subprocess.run", run): + content = read_ref_file("feat/a", ".goga/history/2026/feat-a/todo.md") + + assert content == "Pay�ment\n" + assert run.call_args.kwargs["errors"] == "replace" + + def test_read_ref_file_empty_file_returns_empty_string(self) -> None: + """An empty file is present — ``""`` differs from absence (``None``).""" + run = mock.Mock(return_value=_git_answer("")) + + with mock.patch("goga.topics.git.trees.subprocess.run", run): + content = read_ref_file("feat/a", ".goga/history/2026/feat-a/todo.md") + + assert content == "" + assert run.call_count == 1 diff --git a/tests/topics/test_board.py b/tests/topics/test_board.py new file mode 100644 index 00000000..20a0c0fc --- /dev/null +++ b/tests/topics/test_board.py @@ -0,0 +1,589 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/CODEMANIFEST`` with ``location: board.py``: + +- ``BoardRecord(topic, branch, statuses, current, remote, todo)`` — one row + of the topic board, a topic hosted by one branch with its todo summary +- ``collect_topic_board(year, remote)`` — the read-only cross-branch topic + inventory of one year + +The git boundary is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched; the working-copy +scenarios use ``tmp_path`` + ``monkeypatch.chdir`` with the real history +path routines, and the scale is the ``builtin_scale`` fixture. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import subprocess +import typing +from collections.abc import Callable +from pathlib import Path +from unittest import mock + +import click +import pytest +from goga.history.statuses import StatusScale +from goga.topics import BoardRecord, board, collect_topic_board +from goga.topics.git import BranchRef + +from tests.conftest import is_kw_only_dataclass + +# --- Shared scenario helpers --- + + +def _trees_reader(trees: dict[str, list[str]]) -> Callable[..., list[str]]: + """A ``read_ref_tree_paths`` stand-in answering by ref display name.""" + + def read(ref: str, prefix: str) -> list[str]: + assert prefix == ".goga/history/", "the board reads under the history root only" + return [path for path in trees.get(ref, []) if path.startswith(prefix)] + + return read + + +def _files_reader(files: dict[tuple[str, str], str]) -> Callable[..., str | None]: + """A ``read_ref_file`` stand-in answering by ``(ref, path)``. + + A key missing from the dict answers ``None`` — the mirror of the + ``read_ref_file`` absence contract. + """ + + def read(ref: str, path: str) -> str | None: + return files.get((ref, path)) + + return read + + +def _wire_board( # noqa: PLR0913, PLR0917 — the five board patch points plus the scenario files + monkeypatch: pytest.MonkeyPatch, + scale: StatusScale, + inventory: list[BranchRef], + trees: dict[str, list[str]], + current: str | None, + files: dict[tuple[str, str], str] | None = None, +) -> None: + """Patch the board's import points: scale, git, trees, branch, files. + + Without ``files`` every ref todo reads as ``None`` — no todo.md at + any ref. + """ + monkeypatch.setattr(board, "assemble_status_scale", lambda: scale) + monkeypatch.setattr(board, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(board, "resolve_current_branch_name", lambda: current) + monkeypatch.setattr(board, "read_ref_tree_paths", _trees_reader(trees)) + monkeypatch.setattr(board, "read_ref_file", _files_reader(files or {})) + + +def _working_copy_topic(cwd: Path, year: str, slug: str, artifacts: list[str]) -> None: + """Create the working-copy topic directory with its artifact files.""" + for artifact in artifacts: + path = cwd / ".goga" / "history" / year / slug / artifact + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("artifact", encoding="utf-8") + + +def _working_todo(cwd: Path, year: str, slug: str, content: str) -> None: + """Write the working-copy todo file of a topic with the given content.""" + path = cwd / ".goga" / "history" / year / slug / "todo.md" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _base_inventory() -> list[BranchRef]: + """The design-scenario inventory: two locals, two remote-tracking refs.""" + return [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/feat/a", remote=True), + BranchRef(name="origin/feat/b", remote=True), + BranchRef(name="main", remote=False), + ] + + +def _base_trees() -> dict[str, list[str]]: + """The design-scenario ref trees: one planned topic, one defined topic.""" + return { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "origin/feat/a": [".goga/history/2026/feat-a/plan.md"], + "origin/feat/b": [".goga/history/2026/feat-b/prd.md"], + "main": ["README.md"], + } + + +def _rows(records: list[BoardRecord]) -> list[tuple[str, str, list[str], bool, bool, str | None]]: + """The records as plain tuples — topic, branch, statuses, current, remote, todo.""" + return [ + ( + record.topic, + record.branch, + record.statuses, + record.current, + record.remote, + record.todo, + ) + for record in records + ] + + +# --- Contract tests --- + + +class TestBoardContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """``BoardRecord`` and ``collect_topic_board`` live on the cell facade.""" + import goga.topics as cell + + assert cell.BoardRecord is BoardRecord + assert cell.collect_topic_board is collect_topic_board + assert "BoardRecord" in cell.__all__ + assert "collect_topic_board" in cell.__all__ + + def test_board_record_is_a_frozen_kw_only_dataclass(self) -> None: + """``@dataclass(frozen=True, kw_only=True)`` with the six declared fields.""" + assert dataclasses.is_dataclass(BoardRecord) + assert BoardRecord.__dataclass_params__.frozen is True + assert is_kw_only_dataclass(BoardRecord) + assert typing.get_type_hints(BoardRecord) == { + "topic": str, + "branch": str, + "statuses": list[str], + "current": bool, + "remote": bool, + "todo": str | None, + } + record = BoardRecord(topic="feat-a", branch="feat/a", statuses=["planned"], current=True, remote=False) + assert record.topic == "feat-a" + assert record.branch == "feat/a" + assert record.statuses == ["planned"] + assert record.current is True + assert record.remote is False + assert record.todo is None + with pytest.raises(dataclasses.FrozenInstanceError): + record.topic = "other" # type: ignore[misc] + with pytest.raises(TypeError): + BoardRecord("feat-a", "feat/a", ["planned"], True, False) # type: ignore[misc] + + def test_board_record_declares_todo_field(self) -> None: + """The todo field: ``str | None``, sixth, defaulting to ``None``.""" + hints = typing.get_type_hints(BoardRecord) + assert hints["todo"] == str | None + assert [field.name for field in dataclasses.fields(BoardRecord)] == [ + "topic", + "branch", + "statuses", + "current", + "remote", + "todo", + ] + # The default keeps every pre-todo constructor valid. + record = BoardRecord(topic="a", branch="b", statuses=[], current=False, remote=False) + assert record.todo is None + with_todo = BoardRecord(topic="a", branch="b", statuses=[], current=False, remote=False, todo="Payment retry") + assert with_todo.todo == "Payment retry" + + def test_collect_topic_board_signature(self) -> None: + """``collect_topic_board(year=None, remote=False) -> list[BoardRecord]``.""" + signature = inspect.signature(collect_topic_board) + assert list(signature.parameters) == ["year", "remote"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + assert signature.parameters["remote"].default is False + hints = typing.get_type_hints(collect_topic_board) + assert hints == {"year": str | None, "remote": bool, "return": list[BoardRecord]} + + +# --- Logic tests --- + + +class TestCollectTopicBoard: + def test_collect_topic_board_local_collapses_twin_and_marks_current( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Local mode: full inventory, the twin collapses, the host is marked.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + _wire_board(monkeypatch, builtin_scale, _base_inventory(), _base_trees(), "feat/a") + + records = collect_topic_board("2026", remote=False) + + assert _rows(records) == [ + ("feat-b", "origin/feat/b", ["defined"], False, True, None), + ("feat-a", "feat/a", ["planned"], True, False, None), + ] + # The remote twin collapsed into the local row — the local branch wins. + assert "origin/feat/a" not in [record.branch for record in records] + # A ref without a topic of the year hosts no row. + assert "main" not in [record.branch for record in records] + # Sorting: feat-b (defined, scale position 1) precedes feat-a (planned, position 6). + assert [record.topic for record in records] == ["feat-b", "feat-a"] + + def test_collect_topic_board_remote_mode_twin_current( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Remote mode: remote-tracking rows only; the twin carries the marker.""" + monkeypatch.chdir(tmp_path) + _wire_board(monkeypatch, builtin_scale, _base_inventory(), _base_trees(), "feat/a") + statuses = mock.Mock() + monkeypatch.setattr(board, "resolve_topic_status", statuses) + + records = collect_topic_board("2026", remote=True) + + assert _rows(records) == [ + ("feat-b", "origin/feat/b", ["defined"], False, True, None), + ("feat-a", "origin/feat/a", ["planned"], True, True, None), + ] + assert [record.branch for record in records] == ["origin/feat/b", "origin/feat/a"] + # Remote mode never reads the working copy — the current branch shows + # through its remote twin. + assert statuses.call_count == 0 + + def test_collect_topic_board_year_without_topics_empty( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A year without hosted topics yields an empty list — not an error.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + _wire_board(monkeypatch, builtin_scale, _base_inventory(), _base_trees(), "feat/a") + + assert collect_topic_board("2030") == [] + + def test_collect_topic_board_reads_todo_summaries_local_and_ref( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Todos: the working copy for the current branch, ref trees for the rest.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + _working_todo(tmp_path, "2026", "feat-a", "Local summary\nsecond\n") + trees = { + **_base_trees(), + # Without a year topic on main there is no main row — and the + # absent-todo case stays unmeasured. + "main": [".goga/history/2026/main-only/prd.md", "README.md"], + } + files = {("origin/feat/b", ".goga/history/2026/feat-b/todo.md"): "Remote summary\n"} + _wire_board(monkeypatch, builtin_scale, _base_inventory(), trees, "feat/a", files) + + records = collect_topic_board("2026") + + assert _rows(records) == [ + ("feat-b", "origin/feat/b", ["defined"], False, True, "Remote summary"), + ("main-only", "main", ["defined"], False, False, None), + ("feat-a", "feat/a", ["planned"], True, False, "Local summary"), + ] + + def test_collect_topic_board_todo_first_qualifying_line_and_empty( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A multi-line todo yields its first qualifying line; empty stays empty; absent is None.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + _working_todo(tmp_path, "2026", "feat-a", "# A\nB\n") + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/b", remote=False), + BranchRef(name="feat/c", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/b": [".goga/history/2026/feat-b/plan.md"], + "feat/c": [".goga/history/2026/feat-c/plan.md"], + } + files = {("feat/b", ".goga/history/2026/feat-b/todo.md"): ""} + _wire_board(monkeypatch, builtin_scale, inventory, trees, "feat/a", files) + + records = collect_topic_board("2026") + + # All planned — the order is the slug alphabet, never the todos. + assert _rows(records) == [ + ("feat-a", "feat/a", ["planned"], True, False, "A"), + ("feat-b", "feat/b", ["planned"], False, False, ""), + ("feat-c", "feat/c", ["planned"], False, False, None), + ] + + def test_collect_topic_board_todo_only_topic_is_todo( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A topic whose only artifact is the todo file carries ``todo``.""" + monkeypatch.chdir(tmp_path) + _working_todo(tmp_path, "2026", "feat-a", "Local summary\n") + trees = { + **_base_trees(), + "feat/a": [".goga/history/2026/feat-a/todo.md"], + "origin/feat/b": [".goga/history/2026/feat-b/todo.md"], + } + files = {("origin/feat/b", ".goga/history/2026/feat-b/todo.md"): "Remote summary\n"} + _wire_board(monkeypatch, builtin_scale, _base_inventory(), trees, "feat/a", files) + + records = collect_topic_board("2026") + + # todo.md is the artifact of todo — on the working-copy path and on + # the ref-tree path alike; the summaries ride along. + assert _rows(records) == [ + ("feat-a", "feat/a", ["todo"], True, False, "Local summary"), + ("feat-b", "origin/feat/b", ["todo"], False, True, "Remote summary"), + ] + + def test_collect_topic_board_survives_undecodable_working_todo( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A hand-edited non-UTF-8 working todo degrades — the board lives.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + todo_path = tmp_path / ".goga" / "history" / "2026" / "feat-a" / "todo.md" + todo_path.write_bytes(b"Pay\xffment\n") + _wire_board(monkeypatch, builtin_scale, _base_inventory(), _base_trees(), "feat/a") + + records = collect_topic_board("2026") + + # The read replaces the undecodable byte instead of raising through + # the clean-error boundary — the todo summary is display data. + assert _rows(records) == [ + ("feat-b", "origin/feat/b", ["defined"], False, True, None), + ("feat-a", "feat/a", ["planned"], True, False, "Pay�ment"), + ] + + def test_current_branch_empty_slug_hosts_no_topic( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A fully non-ASCII current branch hosts nothing — and breaks nothing.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="🚀", remote=False), BranchRef(name="feat/a", remote=False)] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"]} + exists = mock.Mock(return_value=False) + monkeypatch.setattr(board, "topic_exists", exists) + _wire_board(monkeypatch, builtin_scale, inventory, trees, "🚀") + + records = collect_topic_board("2026") + + assert _rows(records) == [("feat-a", "feat/a", ["planned"], False, False, None)] + # The empty-slug guard runs before the existence oracle — the board is + # never crashed by the branch that cannot host a topic. + assert exists.call_count == 0 + + def test_collect_topic_board_no_current_branch( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """No current branch — no marker, every status from the ref trees.""" + monkeypatch.chdir(tmp_path) + _wire_board(monkeypatch, builtin_scale, _base_inventory(), _base_trees(), None) + statuses = mock.Mock() + monkeypatch.setattr(board, "resolve_topic_status", statuses) + + records = collect_topic_board("2026") + + assert _rows(records) == [ + ("feat-b", "origin/feat/b", ["defined"], False, True, None), + ("feat-a", "feat/a", ["planned"], False, False, None), + ] + assert all(not record.current for record in records) + # Without a current branch the working copy is not read at all. + assert statuses.call_count == 0 + + def test_board_sees_only_committed_artifacts_on_other_refs( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The current row sees uncommitted progress; the trees see commits only.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/feat/a", remote=True), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/notes.txt"], + "origin/feat/a": [".goga/history/2026/feat-a/notes.txt"], + } + _wire_board(monkeypatch, builtin_scale, inventory, trees, "feat/a") + + local_rows = _rows(collect_topic_board("2026", remote=False)) + remote_rows = _rows(collect_topic_board("2026", remote=True)) + + # The current branch reads the working copy — the uncommitted plan.md + # is visible. + assert local_rows == [("feat-a", "feat/a", ["planned"], True, False, None)] + # The same work through its remote twin reads the ref tree — the + # uncommitted artifact is invisible there. + assert remote_rows == [("feat-a", "origin/feat/a", ["empty"], True, True, None)] + + def test_collect_board_todo_summary_from_ref_tree( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A ref-hosted topic reads its todo summary from the tree — no checkout.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="feat/a", remote=False)] + trees = {"feat/a": [".goga/history/2026/feat-a/todo.md", ".goga/history/2026/feat-a/prd.md"]} + files = {("feat/a", ".goga/history/2026/feat-a/todo.md"): "###\n## Pay retry cap\nbody\n"} + _wire_board(monkeypatch, builtin_scale, inventory, trees, None, files) + + records = collect_topic_board(year="2026") + + assert records[0].statuses == ["defined"] + # The normalization decides the choice of line — the markers-only + # first line never qualifies. + assert records[0].todo == "Pay retry cap" + + def test_collect_board_todo_summary_working_copy( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The current branch's row reads the working copy — and never edits it.""" + monkeypatch.chdir(tmp_path) + todo_path = tmp_path / ".goga" / "history" / "2026" / "feat-a" / "todo.md" + todo_path.parent.mkdir(parents=True, exist_ok=True) + todo_path.write_text("# WIP summary\n", encoding="utf-8") + before = todo_path.read_bytes() + inventory = [BranchRef(name="feat/a", remote=False)] + trees = {"feat/a": [".goga/history/2026/feat-a/todo.md"]} + monkeypatch.setattr(board, "assemble_status_scale", lambda: builtin_scale) + monkeypatch.setattr(board, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(board, "resolve_current_branch_name", lambda: "feat/a") + monkeypatch.setattr(board, "read_ref_tree_paths", _trees_reader(trees)) + read_ref_file = mock.Mock(return_value=None) + monkeypatch.setattr(board, "read_ref_file", read_ref_file) + + records = collect_topic_board(year="2026") + + assert records[0].todo == "WIP summary" + assert records[0].current is True + # The working ref never goes through read_ref_file — the summary + # comes from the working copy, uncommitted progress included. + read_ref_file.assert_not_called() + # The stripping is for display — the file itself is untouched. + assert todo_path.read_bytes() == before + + def test_todo_summary_absent_file_yields_none( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A topic with an artifact but no todo.md reads ``None`` — absence.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="feat/a", remote=False)] + trees = {"feat/a": [".goga/history/2026/feat-a/prd.md"]} + _wire_board(monkeypatch, builtin_scale, inventory, trees, None) + + records = collect_topic_board(year="2026") + + assert records[0].statuses == ["defined"] + assert records[0].todo is None + + def test_board_old_title_txt_only_is_empty_status( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A topic carrying only the retired title.txt is ``empty`` — a clean break.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="feat/a", remote=False)] + trees = {"feat/a": [".goga/history/2026/feat-a/title.txt"]} + files = {("feat/a", ".goga/history/2026/feat-a/title.txt"): "Retired\n"} + _wire_board(monkeypatch, builtin_scale, inventory, trees, None, files) + + records = collect_topic_board(year="2026") + + # title.txt stopped being an artifact — the topic has nothing the + # scale recognizes, and no todo.md to summarize. + assert records[0].statuses == ["empty"] + assert records[0].todo is None + + +class TestTodoSummaryNormalization: + def test_todo_summary_markers_only_yields_empty(self) -> None: + """Lines of # markers alone never qualify — the file exists, the summary is empty.""" + assert board._todo_summary("###\n##\n#\n") == "" + + def test_todo_summary_marker_not_at_line_start(self) -> None: + """A marker after leading blanks is text — only line-start markers strip.""" + assert board._todo_summary(" # indented marker\n") == "# indented marker" + + def test_todo_summary_whitespace_only_yields_empty(self) -> None: + """Lines that strip to nothing never qualify — the file exists, the summary is empty.""" + assert board._todo_summary(" \n\t\n") == "" + + +class TestBoardInfrastructureBoundary: + def test_git_failure_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A git infrastructure failure with stderr becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + failure = subprocess.CalledProcessError( + returncode=128, cmd=["git", "for-each-ref"], stderr="fatal: not a git repository" + ) + monkeypatch.setattr(board, "assemble_status_scale", lambda: builtin_scale) + monkeypatch.setattr(board, "list_branch_refs", mock.Mock(side_effect=failure)) + + with pytest.raises(click.ClickException) as raised: + collect_topic_board("2026") + + assert "fatal: not a git repository" in raised.value.message + + def test_missing_git_binary_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A missing git binary becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(board, "assemble_status_scale", lambda: builtin_scale) + monkeypatch.setattr(board, "list_branch_refs", mock.Mock(side_effect=FileNotFoundError("git"))) + + with pytest.raises(click.ClickException): + collect_topic_board("2026") + + def test_broken_tool_package_import_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The fatal scale-assembly ``ImportError`` keeps its package name.""" + monkeypatch.chdir(tmp_path) + broken = ImportError("package goga_tool_bad failed to import: boom") + monkeypatch.setattr(board, "assemble_status_scale", mock.Mock(side_effect=broken)) + + with pytest.raises(click.ClickException) as raised: + collect_topic_board("2026") + + assert raised.value.message == "package goga_tool_bad failed to import: boom" diff --git a/tests/topics/test_creation.py b/tests/topics/test_creation.py new file mode 100644 index 00000000..d02c5855 --- /dev/null +++ b/tests/topics/test_creation.py @@ -0,0 +1,1187 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/CODEMANIFEST`` with ``location: creation.py``: + +- ``check_branch_occupancy(branch_name, slug, year)`` — the three-oracle + occupancy check of a fresh-work name +- ``check_slug_occupancy(slug, year)`` — the branch-tree occupancy oracle of + a topic slug +- ``create_topic(branch_name, base_ref, todo, publish, commit_message, + year, switch)`` — the fresh-work creation procedure off an explicit base + with its editor-sourced todo and its publication ask: the quarantined + no-switch plant by default, the working-copy switch path under the flag +- ``enter_topic_todo(topic, year)`` — the editor session over the topic's + todo.md and the write of the saved text, without a commit + +The git boundary is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched. The filesystem +scenarios (the topic oracle and the created directory) run against ``tmp_path`` +with the real history path routines; the scale is never assembled — creation +is not a status consumer. The editor session is mocked with a shell script +exported as ``$EDITOR`` per the ``editor`` practice and the TTY detection +with a ``sys.stdin`` stand-in — a real editor never launches in tests. +""" + +from __future__ import annotations + +import inspect +import subprocess +import sys +import typing +from collections.abc import Callable +from pathlib import Path +from unittest import mock + +import click +import pytest +from goga.topics import ( + check_branch_occupancy, + check_slug_occupancy, + create_topic, + creation, + enter_topic_todo, + publishing, +) +from goga.topics.git import BranchRef + +# --- Shared scenario helpers --- + + +def _wire_inventory( + monkeypatch: pytest.MonkeyPatch, + inventory: list[BranchRef], + current: str | None = None, +) -> None: + """Patch creation's import points: the inventory and the current branch.""" + monkeypatch.setattr(creation, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(creation, "resolve_current_branch_name", lambda: current) + + +def _wire_creation( + monkeypatch: pytest.MonkeyPatch, + current: str = "main", + base_commit: str = "c0ffee", +) -> mock.Mock: + """Patch creation's import points: a free inventory, the current + branch, the base resolution, and the create/checkout mutations, plus + the quarantined plant the no-switch path reaches through publishing. + + Returns: + A recording parent mock whose ``resolve_ref_commit``, + ``create_branch``, ``checkout``, and ``plant`` children are the + wired touchpoints — ``mock_calls`` captures the procedure's order. + """ + wired = mock.Mock() + wired.resolve_ref_commit.return_value = base_commit + _wire_inventory(monkeypatch, [], current) + monkeypatch.setattr(creation, "resolve_ref_commit", wired.resolve_ref_commit) + monkeypatch.setattr(creation, "create_branch_at_commit", wired.create_branch) + monkeypatch.setattr(creation, "checkout_local_branch", wired.checkout) + monkeypatch.setattr(publishing, "_plant_topic_branch", wired.plant) + return wired + + +def _non_interactive(monkeypatch: pytest.MonkeyPatch) -> None: + """Make stdin a non-terminal — the value-less todo must abort cleanly.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + +def _topic_dir(cwd: Path, year: str, slug: str) -> Path: + """Create the working-copy topic directory of the oracle scenarios.""" + path = cwd / ".goga" / "history" / year / slug + path.mkdir(parents=True, exist_ok=True) + return path + + +def _editor_script(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, body: str) -> None: + """Export ``$EDITOR`` as an executable shell script running ``body``.""" + editors = tmp_path / "editors" + editors.mkdir(exist_ok=True) + script = editors / "editor-mock.sh" + script.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8") + script.chmod(0o755) + monkeypatch.delenv("VISUAL", raising=False) + monkeypatch.setenv("EDITOR", str(script)) + + +def _tty(monkeypatch: pytest.MonkeyPatch) -> None: + """Make stdin a terminal — the editor session launches.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + + +def _wire_slug_oracle( + monkeypatch: pytest.MonkeyPatch, + inventory: list[BranchRef], + reader: Callable[[str, str], list[str]], +) -> mock.Mock: + """Patch creation's import points: the inventory and the tree reader. + + Returns: + The branch-ref listing as a recording mock — the oracle's only + other git touchpoint. + """ + listing = mock.Mock(return_value=inventory) + monkeypatch.setattr(creation, "list_branch_refs", listing) + monkeypatch.setattr(creation, "read_ref_tree_paths", reader) + return listing + + +# --- Contract tests --- + + +class TestCreationContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """Both routines live on the cell facade and in ``__all__``.""" + import goga.topics as cell + + assert cell.create_topic is create_topic + assert cell.check_branch_occupancy is check_branch_occupancy + assert cell.check_slug_occupancy is check_slug_occupancy + assert cell.enter_topic_todo is enter_topic_todo + expected = { + "BoardRecord", + "DeleteTarget", + "SwitchCandidate", + "check_branch_occupancy", + "check_slug_occupancy", + "collect_topic_board", + "create_topic", + "delete_topics", + "ensure_topic", + "enter_topic_todo", + "publish_topic", + "resolve_delete_targets", + "resolve_switch_candidates", + "switch_topic", + } + assert set(cell.__all__) == expected + + def test_check_slug_occupancy_signature(self) -> None: + """``check_slug_occupancy(slug, year=None)``.""" + signature = inspect.signature(check_slug_occupancy) + assert list(signature.parameters) == ["slug", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(check_slug_occupancy) + assert hints == { + "slug": str, + "year": str | None, + "return": str | None, + } + + def test_check_branch_occupancy_signature(self) -> None: + """``check_branch_occupancy(branch_name, slug, year=None)``.""" + signature = inspect.signature(check_branch_occupancy) + assert list(signature.parameters) == ["branch_name", "slug", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(check_branch_occupancy) + assert hints == { + "branch_name": str, + "slug": str, + "year": str | None, + "return": str | None, + } + + def test_enter_topic_todo_signature(self) -> None: + """``enter_topic_todo(topic, year=None) -> bool`` — binds as declared.""" + signature = inspect.signature(enter_topic_todo) + assert list(signature.parameters) == ["topic", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(enter_topic_todo) + assert hints == { + "topic": str, + "year": str | None, + "return": bool, + } + signature.bind("feature-foo", year="2026") + + def test_create_topic_signature(self) -> None: + """``create_topic(branch_name, base_ref, todo, publish, commit_message, year, switch) -> str`` + with the defaults ``None/False/None/None/False``.""" + signature = inspect.signature(create_topic) + assert list(signature.parameters) == [ + "branch_name", + "base_ref", + "todo", + "publish", + "commit_message", + "year", + "switch", + ] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["todo"].default is None + assert signature.parameters["publish"].default is False + assert signature.parameters["commit_message"].default is None + assert signature.parameters["year"].default is None + assert signature.parameters["switch"].default is False + hints = typing.get_type_hints(create_topic) + assert hints == { + "branch_name": str, + "base_ref": str, + "todo": str | None, + "publish": bool, + "commit_message": str | None, + "year": str | None, + "switch": bool, + "return": str, + } + signature.bind("b", "origin/main", todo="t", publish=False, commit_message=None, year="2026", switch=True) + signature.bind("b", "HEAD") + + def test_no_cleanliness_probe_in_creation(self) -> None: + """Creation owns no cleanliness policy — no probe is imported.""" + assert not hasattr(creation, "is_working_tree_clean") + + +# --- Logic tests: the occupancy oracles --- + + +class TestCheckBranchOccupancy: + def test_check_branch_occupancy_oracle_order(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The first occupied oracle wins — remote twin before the topic dir.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="origin/feat/x", remote=True)] + _topic_dir(tmp_path, "2026", "feat-x") + _wire_inventory(monkeypatch, inventory) + + conflict = check_branch_occupancy("feat/x", "feat-x", "2026") + + assert conflict == "remote-tracking branch 'feat/x' already exists" + + def test_check_branch_occupancy_local_ref_oracle(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A local branch of the name occupies it — the first oracle.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/x", remote=False), + BranchRef(name="origin/feat/x", remote=True), + ] + _topic_dir(tmp_path, "2026", "feat-x") + _wire_inventory(monkeypatch, inventory) + + conflict = check_branch_occupancy("feat/x", "feat-x", "2026") + + assert conflict == "branch 'feat/x' already exists" + + def test_check_branch_occupancy_topic_dir_oracle(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The topic directory of the year occupies the slug — the last oracle.""" + monkeypatch.chdir(tmp_path) + _wire_inventory(monkeypatch, []) + _topic_dir(tmp_path, "2026", "feat-x") + + conflict = check_branch_occupancy("feat/x", "feat-x", "2026") + + assert conflict == "history topic 'feat-x' already exists for 2026" + + def test_check_branch_occupancy_topic_of_another_year_is_free( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The topic oracle is year-scoped — another year's topic is free.""" + monkeypatch.chdir(tmp_path) + _wire_inventory(monkeypatch, []) + _topic_dir(tmp_path, "2025", "feat-x") + + assert check_branch_occupancy("feat/x", "feat-x", "2026") is None + + def test_check_branch_occupancy_default_year_is_current( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``year=None`` resolves to the current year — the reported one.""" + monkeypatch.chdir(tmp_path) + _wire_inventory(monkeypatch, []) + _topic_dir(tmp_path, "2026", "feat-x") + monkeypatch.setattr(creation, "current_year", lambda: "2026") + + conflict = check_branch_occupancy("feat/x", "feat-x") + + assert conflict == "history topic 'feat-x' already exists for 2026" + + def test_check_branch_occupancy_free_everywhere(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Every oracle free — ``None``, not an error.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="origin/feat/other", remote=True)] + _wire_inventory(monkeypatch, inventory) + + assert check_branch_occupancy("feat/x", "feat-x", "2026") is None + + +# --- Logic tests: the branch-tree slug oracle --- + + +class TestCheckSlugOccupancy: + def test_check_slug_occupancy_returns_first_hosting_branch( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The first ref whose tree carries the topic directory names the conflict.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="alpha", remote=False), + BranchRef(name="beta", remote=True), + ] + reader = mock.Mock(side_effect=[[], [".goga/history/2026/feature-foo/todo.md"]]) + listing = _wire_slug_oracle(monkeypatch, inventory, reader) + + conflict = check_slug_occupancy("feature-foo", "2026") + + assert conflict == ("topic 'feature-foo' of 2026 is already hosted by branch 'beta'") + assert reader.call_args.args == ("beta", ".goga/history/2026/feature-foo/") + listing.assert_called_once_with() + + def test_check_slug_occupancy_stops_at_first_hit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The first occupied ref wins — the remaining refs are not probed.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="alpha", remote=False), + BranchRef(name="beta", remote=False), + BranchRef(name="gamma", remote=False), + ] + reader = mock.Mock( + side_effect=[ + [".goga/history/2026/feature-foo/todo.md"], + [".goga/history/2026/feature-foo/todo.md"], + [".goga/history/2026/feature-foo/todo.md"], + ] + ) + _wire_slug_oracle(monkeypatch, inventory, reader) + + conflict = check_slug_occupancy("feature-foo", "2026") + + assert conflict == ("topic 'feature-foo' of 2026 is already hosted by branch 'alpha'") + assert reader.call_count == 1 + + def test_check_slug_occupancy_free_slug_returns_none(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """No ref hosts the slug — None, one probe per ref.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="alpha", remote=False), + BranchRef(name="origin/beta", remote=True), + ] + reader = mock.Mock(return_value=[]) + listing = _wire_slug_oracle(monkeypatch, inventory, reader) + + assert check_slug_occupancy("feature-foo", "2026") is None + assert reader.call_count == 2 + listing.assert_called_once_with() + + def test_check_slug_occupancy_does_not_match_sibling_slug_prefix( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A sibling slug sharing the prefix text hosts nothing — the trailing slash. + + The real ``startswith`` filter lives inside the reader, which is + mocked away here — the emulation keeps the oracle honest about what + the reader contract returns. + """ + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="alpha", remote=False)] + paths = [".goga/history/2026/feature-foo-bar/todo.md"] + received: list[str] = [] + + def emulate_reader(ref: str, prefix: str) -> list[str]: + received.append(prefix) + return [path for path in paths if path.startswith(prefix)] + + _wire_slug_oracle(monkeypatch, inventory, emulate_reader) + + assert check_slug_occupancy("feature-foo", "2026") is None + assert received == [".goga/history/2026/feature-foo/"] + + def test_check_slug_occupancy_ignores_disk_only_topics( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A topic living only in the working copy is invisible to this oracle.""" + monkeypatch.chdir(tmp_path) + topic_dir = _topic_dir(tmp_path, "2026", "feature-foo") + (topic_dir / "todo.md").write_text("On disk only\n", encoding="utf-8") + inventory = [BranchRef(name="alpha", remote=False)] + reader = mock.Mock(return_value=[]) + _wire_slug_oracle(monkeypatch, inventory, reader) + + assert check_slug_occupancy("feature-foo", "2026") is None + + def test_check_slug_occupancy_default_year_is_current( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``year=None`` resolves to the current year — the probe is year-scoped.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="alpha", remote=False)] + reader = mock.Mock(return_value=[".goga/history/2026/feature-foo/todo.md"]) + _wire_slug_oracle(monkeypatch, inventory, reader) + monkeypatch.setattr(creation, "current_year", lambda: "2026") + + conflict = check_slug_occupancy("feature-foo") + + assert conflict == ("topic 'feature-foo' of 2026 is already hosted by branch 'alpha'") + assert reader.call_args.args == ("alpha", ".goga/history/2026/feature-foo/") + + +# --- Logic tests: the creation procedure --- + + +class TestCreateTopic: + def test_create_topic_no_switch_path_order(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The default path quarantines the topic into the branch — no switch. + + The preflight resolves the base once, the quarantined plant builds + one commit carrying todo.md on it and plants the branch there, and + nothing else runs: no checkout, no working-copy directory, no + working-copy todo file — the declined publication ask keeps the + work local. + """ + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main", base_commit="c0ffee") + _tty(monkeypatch) + monkeypatch.setattr(click, "confirm", mock.Mock(return_value=False)) + + result = create_topic("feature-foo", "origin/main", todo="Fix.", year="2026") + + assert result == "Created branch feature-foo and topic 2026/feature-foo" + assert wired.mock_calls == [ + mock.call.resolve_ref_commit("origin/main"), + mock.call.plant("feature-foo", "Fix.", "c0ffee", "feature-foo", "2026", None), + ] + assert not (tmp_path / ".goga" / "history" / "2026").exists() + + def test_create_topic_switch_path_order(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The switch path runs its actions in the fixed order. + + The branch is planted at the base commit the preflight resolved, + the checkout follows, then the topic directory, and the todo + write is the last action of the path — the declined publication + ask keeps the work local and the quarantined plant never runs. + """ + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main", base_commit="c0ffee") + wired.ensure_topic_dir.side_effect = lambda name, year: _topic_dir(tmp_path, year, name) + monkeypatch.setattr(creation, "ensure_topic_dir", wired.ensure_topic_dir) + _tty(monkeypatch) + monkeypatch.setattr(click, "confirm", mock.Mock(return_value=False)) + + result = create_topic("feature-foo", "origin/main", todo="Fix.", year="2026", switch=True) + + assert result == "Created branch feature-foo and topic 2026/feature-foo" + assert wired.mock_calls == [ + mock.call.resolve_ref_commit("origin/main"), + mock.call.create_branch("feature-foo", "c0ffee"), + mock.call.checkout("feature-foo"), + mock.call.ensure_topic_dir("feature-foo", "2026"), + ] + todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo" / "todo.md" + assert todo_file.read_text(encoding="utf-8") == "Fix.\n" + + def test_create_topic_base_passed_to_the_plant(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The base is resolved once and the quarantined commit is built on it. + + The no-switch default hands the plant the resolved base commit and + the built-in message — the template argument stays None. + """ + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, base_commit="abc123") + + create_topic("feat-a", "origin/main", todo="T", year="2026") + + wired.resolve_ref_commit.assert_called_once_with("origin/main") + wired.plant.assert_called_once_with("feat-a", "T", "abc123", "feat-a", "2026", None) + + def test_create_topic_switch_path_plants_at_the_base_commit( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The switch path plants the branch at the once-resolved base commit.""" + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, base_commit="abc123") + + create_topic("feat-a", "origin/main", todo="T", year="2026", switch=True) + + wired.resolve_ref_commit.assert_called_once_with("origin/main") + wired.create_branch.assert_called_once_with("feat-a", "abc123") + wired.plant.assert_not_called() + + def test_create_topic_publication_ask_yes_delegates(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An accepted ask delegates the whole work to the publication cycle. + + The delegation reaches ``publish_topic`` at its definition site — + the call-time import resolves the patched attribute — with the + name, the resolved todo, the base, the template, and the year; + none of the local mutations runs. + """ + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch) + confirm = mock.Mock(return_value=True) + monkeypatch.setattr(click, "confirm", confirm) + published = mock.Mock(return_value="published line") + monkeypatch.setattr(publishing, "publish_topic", published) + _tty(monkeypatch) + + result = create_topic("feature-foo", "origin/main", todo="Fix.", year="2026") + + assert result == "published line" + confirm.assert_called_once_with("Publish the branch to origin?") + published.assert_called_once_with("feature-foo", "Fix.", "origin/main", None, "2026") + wired.create_branch.assert_not_called() + wired.checkout.assert_not_called() + + def test_create_topic_publication_ask_empty_answer_is_no( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An empty answer at the ask reads the default no — the work stays local. + + The local outcome of the declined ask is the no-switch plant — no + checkout runs. + """ + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch) + _tty(monkeypatch) + monkeypatch.setattr(click.termui, "visible_prompt_func", mock.Mock(return_value="")) + + result = create_topic("feature-foo", "origin/main", todo="Fix.", year="2026") + + assert result == "Created branch feature-foo and topic 2026/feature-foo" + wired.plant.assert_called_once_with("feature-foo", "Fix.", "c0ffee", "feature-foo", "2026", None) + wired.checkout.assert_not_called() + + def test_create_topic_failed_checkout_rolls_back_the_plant( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failed checkout deletes the planted branch — no stranded name. + + The pre-rework ``git switch -c`` was atomic; the plant-then-checkout + split is not. A stranded plant would block the retry (the occupancy + oracle answers "already exists") and the deletion flow cannot + remove it (a bare branch hosts no topic) — the rollback mirrors + ``publish_topic``'s push-failure tail. + """ + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch) + monkeypatch.setattr(creation, "delete_local_branch", wired.delete_branch) + wired.checkout.side_effect = subprocess.CalledProcessError( + 1, + ["git", "switch", "feature-foo"], + stderr=b"error: Your local changes would be overwritten by checkout", + ) + + with pytest.raises(click.ClickException, match="overwritten by checkout"): + create_topic("feature-foo", "origin/main", todo="Fix.", year="2026", switch=True) + + wired.delete_branch.assert_called_once_with("feature-foo") + + def test_create_topic_rollback_failure_still_surfaces_checkout_reason( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A broken rollback is suppressed — the checkout reason surfaces.""" + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch) + monkeypatch.setattr(creation, "delete_local_branch", wired.delete_branch) + wired.checkout.side_effect = subprocess.CalledProcessError( + 1, ["git", "switch", "feature-foo"], stderr=b"checkout refused" + ) + wired.delete_branch.side_effect = subprocess.CalledProcessError( + 1, ["git", "update-ref", "-d"], stderr=b"ref lock" + ) + + with pytest.raises(click.ClickException) as raised: + create_topic("feature-foo", "origin/main", todo="Fix.", year="2026", switch=True) + + assert "checkout refused" in raised.value.message + assert "ref lock" not in raised.value.message + + def test_create_topic_editor_todo_on_tty(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Without a value the terminal opens the editor; the saved text is + written with exactly one trailing newline. + + The editor's read-back already ends with a newline — the shared + write helper must not double it. The switch path carries the text + into the working copy. + """ + monkeypatch.chdir(tmp_path) + _wire_creation(monkeypatch) + _editor_script(monkeypatch, tmp_path, "printf 'From editor.\\n' > \"$1\"") + _tty(monkeypatch) + monkeypatch.setattr(click, "confirm", mock.Mock(return_value=False)) + + result = create_topic("feature-foo", "HEAD", year="2026", switch=True) + + assert result == "Created branch feature-foo and topic 2026/feature-foo" + todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo" / "todo.md" + assert todo_file.read_text(encoding="utf-8") == "From editor.\n" + + def test_create_topic_base_resolved_in_preflight_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An unresolvable base is a preflight error — before any input. + + The editor sentinel never launches: a failing base must not waste + an entered todo, and no mutation runs. + """ + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch) + wired.resolve_ref_commit.side_effect = subprocess.CalledProcessError( + 128, ["git", "rev-parse", "no-such-ref"], stderr=b"fatal: bad revision" + ) + marker = tmp_path / "editor-launched" + _editor_script(monkeypatch, tmp_path, f"touch '{marker}'") + _tty(monkeypatch) + + with pytest.raises(click.ClickException, match="bad revision"): + create_topic("feature-foo", "no-such-ref", year="2026") + + assert not marker.exists() + wired.create_branch.assert_not_called() + wired.checkout.assert_not_called() + + def test_create_topic_empty_slug_preflight_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An empty slug is the first preflight error — nothing else runs. + + The current-branch check, the occupancy oracles, the base + resolution, and the editor all stay untouched. + """ + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch) + probes = mock.Mock() + monkeypatch.setattr(creation, "resolve_current_branch_name", probes.current_branch) + monkeypatch.setattr(creation, "check_branch_occupancy", probes.branch_oracle) + monkeypatch.setattr(creation, "check_slug_occupancy", probes.slug_oracle) + marker = tmp_path / "editor-launched" + _editor_script(monkeypatch, tmp_path, f"touch '{marker}'") + _tty(monkeypatch) + + with pytest.raises(click.ClickException, match="empty topic slug"): + create_topic("???", "origin/main", todo="x", year="2026") + + probes.assert_not_called() + wired.resolve_ref_commit.assert_not_called() + wired.create_branch.assert_not_called() + assert not marker.exists() + + def test_create_topic_todo_non_tty_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """No todo value without a terminal is a clean error naming the + value option — before any mutation.""" + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch) + marker = tmp_path / "editor-launched" + _editor_script(monkeypatch, tmp_path, f"touch '{marker}'") + _non_interactive(monkeypatch) + + with pytest.raises(click.ClickException, match="--todo"): + create_topic("feature-foo", "origin/main", year="2026") + + assert not marker.exists() + wired.create_branch.assert_not_called() + wired.checkout.assert_not_called() + + def test_create_topic_publish_without_todo_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The publication path with a cancelled editor entry is a clean + error asking for the todo — a todo-less publish never happens.""" + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch) + _editor_script(monkeypatch, tmp_path, "exit 0") + _tty(monkeypatch) + + with pytest.raises(click.ClickException, match="needs a todo"): + create_topic("feature-foo", "origin/main", publish=True, year="2026") + + wired.create_branch.assert_not_called() + wired.checkout.assert_not_called() + + def test_create_topic_publish_with_editor_todo_delegates_without_ask( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``publish=True`` delegates the editor-resolved todo — never the ask. + + The fast cycle must receive the resolved text (the editor's + read-back, trailing newline and all), not the absent value option, + and the confirm never fires: ``--publish`` is ask-free by contract. + """ + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch) + _editor_script(monkeypatch, tmp_path, "printf 'From editor.\\n' > \"$1\"") + _tty(monkeypatch) + confirm = mock.Mock() + monkeypatch.setattr(click, "confirm", confirm) + published = mock.Mock(return_value="published line") + monkeypatch.setattr(publishing, "publish_topic", published) + + result = create_topic("feature-foo", "origin/main", publish=True, year="2026") + + assert result == "published line" + published.assert_called_once_with("feature-foo", "From editor.\n", "origin/main", None, "2026") + confirm.assert_not_called() + wired.create_branch.assert_not_called() + wired.checkout.assert_not_called() + + def test_create_topic_current_branch_same_slug_is_conflict( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The current branch hosting the slug is a conflict — the + idempotent path is abolished; no input, no mutation.""" + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="feature-foo") + marker = tmp_path / "editor-launched" + _editor_script(monkeypatch, tmp_path, f"touch '{marker}'") + _tty(monkeypatch) + + with pytest.raises(click.ClickException, match="already hosts"): + create_topic("feature-foo", "origin/main", todo="x") + + assert not marker.exists() + wired.create_branch.assert_not_called() + + def test_create_topic_occupied_name_error_no_reask( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An occupancy conflict is one clean error with the board hint — + the abolished re-ask must not resurrect.""" + monkeypatch.chdir(tmp_path) + _tty(monkeypatch) + _editor_script(monkeypatch, tmp_path, "exit 0") + prompt = mock.Mock() + monkeypatch.setattr(click, "prompt", prompt) + wired = _wire_creation(monkeypatch, current="main") + monkeypatch.setattr( + creation, + "list_branch_refs", + lambda: [BranchRef(name="feat/x", remote=False)], + ) + + with pytest.raises(click.ClickException) as raised: + create_topic("feat/x", "HEAD") + + assert raised.value.message == ("branch 'feat/x' already exists — run 'goga topics board' to see the board") + prompt.assert_not_called() + wired.create_branch.assert_not_called() + assert not (tmp_path / ".goga" / "history").exists() + + def test_create_topic_creates_branch_and_dir_with_cancelled_entry( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A free name with a cancelled editor entry on the switch path: the + verbatim branch and the slug directory — and no todo file.""" + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main") + _editor_script(monkeypatch, tmp_path, "exit 0") + _tty(monkeypatch) + + result = create_topic("Feature/Foo_Bar", "HEAD", year="2025", switch=True) + + assert result == "Created branch Feature/Foo_Bar and topic 2025/feature-foo-bar" + wired.create_branch.assert_called_once_with("Feature/Foo_Bar", "c0ffee") + wired.checkout.assert_called_once_with("Feature/Foo_Bar") + topic_dir = tmp_path / ".goga" / "history" / "2025" / "feature-foo-bar" + assert topic_dir.is_dir() + assert not (topic_dir / "todo.md").exists() + + def test_create_topic_no_switch_cancelled_entry_is_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A cancelled editor entry on the default path is a clean error. + + Git keeps no empty directories — without a committed todo.md the + no-switch work exists in no tree, so the todo is required exactly + as it is under the publication; the error names the two ways out. + """ + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main") + _editor_script(monkeypatch, tmp_path, "exit 0") + _tty(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + create_topic("Feature/Foo_Bar", "HEAD", year="2025") + + assert "the local creation needs a todo" in raised.value.message + assert "todo.md" in raised.value.message + wired.plant.assert_not_called() + wired.create_branch.assert_not_called() + wired.checkout.assert_not_called() + + def test_create_topic_default_year_is_current(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Without a year the topic of the switch-path directory lands in the current one.""" + monkeypatch.chdir(tmp_path) + _wire_creation(monkeypatch, current="main") + _editor_script(monkeypatch, tmp_path, "exit 0") + _tty(monkeypatch) + monkeypatch.setattr(creation, "current_year", lambda: "2026") + + result = create_topic("Feature/Foo_Bar", "HEAD", switch=True) + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + assert (tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar").is_dir() + + def test_create_topic_no_switch_default_year_is_current( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Without a year the quarantined commit carries the current year's path.""" + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main") + monkeypatch.setattr(creation, "current_year", lambda: "2026") + + result = create_topic("Feature/Foo_Bar", "HEAD", todo="T") + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + wired.plant.assert_called_once_with("Feature/Foo_Bar", "T", "c0ffee", "feature-foo-bar", "2026", None) + + def test_create_topic_with_todo_value(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A free name with a todo value: the quarantined plant, nothing on disk. + + The default path hands the resolved todo to the plant — the + working copy keeps no directory and no file. + """ + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main") + + result = create_topic("Feature/Foo_Bar", "HEAD", todo="Payment retry", year="2026") + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + wired.plant.assert_called_once_with( + "Feature/Foo_Bar", "Payment retry", "c0ffee", "feature-foo-bar", "2026", None + ) + wired.checkout.assert_not_called() + assert not (tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar").exists() + + def test_create_topic_with_todo_value_switch_writes_the_file( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A free name with a todo value on the switch path: the branch, the + directory, the todo file.""" + monkeypatch.chdir(tmp_path) + _wire_creation(monkeypatch, current="main") + + result = create_topic("Feature/Foo_Bar", "HEAD", todo="Payment retry", year="2026", switch=True) + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" / "todo.md" + assert todo_file.read_bytes() == b"Payment retry\n" + + def test_create_topic_writes_multiline_todo(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A multi-line todo on the switch path: the file carries the text + verbatim plus one newline.""" + monkeypatch.chdir(tmp_path) + _wire_creation(monkeypatch, current="main") + + result = create_topic( + "Feature/Foo_Bar", + "HEAD", + year="2026", + todo="Fix payment retries.\n\nRetries ignore the cap.", + switch=True, + ) + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + topic_dir = tmp_path / ".goga" / "history" / "2026" / "feature-foo-bar" + # Empty lines inside the text stay as entered; one trailing newline. + assert (topic_dir / "todo.md").read_bytes() == (b"Fix payment retries.\n\nRetries ignore the cap.\n") + # The todo file is the single artifact of the topic directory. + assert [path.name for path in topic_dir.iterdir()] == ["todo.md"] + + def test_create_topic_whitespace_todo_writes_verbatim( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A whitespace-only todo is a non-empty text — the switch path + writes it verbatim.""" + monkeypatch.chdir(tmp_path) + _wire_creation(monkeypatch, current="main") + + result = create_topic("feat-a", "HEAD", year="2026", todo=" ", switch=True) + + assert result == "Created branch feat-a and topic 2026/feat-a" + topic_dir = tmp_path / ".goga" / "history" / "2026" / "feat-a" + assert (topic_dir / "todo.md").read_bytes() == b" \n" + + def test_create_topic_todo_write_failure_is_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failing todo write of the switch path becomes the generalized + clean error.""" + monkeypatch.chdir(tmp_path) + wired = _wire_creation(monkeypatch, current="main") + monkeypatch.setattr( + creation, + "resolve_topic_file", + mock.Mock(side_effect=OSError("disk full")), + ) + + with pytest.raises(click.ClickException) as raised: + create_topic("Feature/Foo_Bar", "HEAD", todo="T", year="2026", switch=True) + + assert "cannot create the topic directory or write the todo file" in raised.value.message + # The traced order — the branch mutations run before the todo write. + wired.create_branch.assert_called_once_with("Feature/Foo_Bar", "c0ffee") + + +# --- Logic tests: the todo entry of a topic --- + + +class TestEnterTopicTodo: + @pytest.mark.parametrize( + ("editor_command", "expected_result", "expected_content"), + [ + ("printf 'New line.\\n' > \"$1\"", True, "New line.\n"), + ("exit 0", False, "Old line.\n"), + ], + ) + def test_enter_topic_todo_edits_existing_file( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + editor_command: str, + expected_result: bool, + expected_content: str, + ) -> None: + """An existing todo.md seeds the session; the saved text overwrites it. + + A cancelled session — an editor that never writes — returns False + and leaves the file verbatim. + """ + monkeypatch.chdir(tmp_path) + todo_file = _topic_dir(tmp_path, "2026", "feature-foo") / "todo.md" + _tty(monkeypatch) + + todo_file.write_text("Old line.\n", encoding="utf-8") + _editor_script(monkeypatch, tmp_path, editor_command) + + assert enter_topic_todo("feature-foo", year="2026") is expected_result + assert todo_file.read_text(encoding="utf-8") == expected_content + + def test_enter_topic_todo_seeds_existing_content(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The session starts from the existing todo.md — the prefill proves it. + + The editor script copies the prefilled temporary file aside instead + of editing: the prefill must carry the existing content verbatim + (the session's own normalization keeps the trailing newline). + """ + monkeypatch.chdir(tmp_path) + todo_file = _topic_dir(tmp_path, "2026", "feature-foo") / "todo.md" + todo_file.write_text("Old line.\n", encoding="utf-8") + prefill = tmp_path / "prefill.txt" + _editor_script(monkeypatch, tmp_path, f"cp \"$1\" '{prefill}'") + _tty(monkeypatch) + + assert enter_topic_todo("feature-foo", year="2026") is False + assert prefill.read_text(encoding="utf-8") == "Old line.\n" + assert todo_file.read_text(encoding="utf-8") == "Old line.\n" + + def test_enter_topic_todo_missing_file_empty_entry(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A missing todo.md starts from an empty entry — the fresh-entry path. + + The topic directory exists without the file — the state after + ``ensure`` on a branch whose tree hosts no todo yet. + """ + monkeypatch.chdir(tmp_path) + _topic_dir(tmp_path, "2026", "feature-foo") + _editor_script(monkeypatch, tmp_path, "printf 'First.\\n' > \"$1\"") + _tty(monkeypatch) + + assert enter_topic_todo("feature-foo", year="2026") is True + + todo_file = tmp_path / ".goga" / "history" / "2026" / "feature-foo" / "todo.md" + assert todo_file.read_text(encoding="utf-8") == "First.\n" + + def test_enter_topic_todo_non_utf8_prefill_decodes_replacement( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A non-UTF-8 todo.md prefills with replacement bytes, not a traceback. + + The prefill is display data — a ``UnicodeDecodeError`` is a + ``ValueError`` that matches none of the module's handlers, so the + strict read would pierce the clean-error boundary of + ``switch --todo`` and ``pipeline --todo``. The editor script only + writes when the prefill carried the readable part, pinning that + the file was actually read. + """ + monkeypatch.chdir(tmp_path) + todo_file = _topic_dir(tmp_path, "2026", "feature-foo") / "todo.md" + todo_file.write_bytes("Old line.\n\xff\n".encode("latin-1")) + _editor_script( + monkeypatch, + tmp_path, + "grep -q 'Old line.' \"$1\" && printf 'New line.\\n' > \"$1\"", + ) + _tty(monkeypatch) + + assert enter_topic_todo("feature-foo", year="2026") is True + assert todo_file.read_text(encoding="utf-8") == "New line.\n" + + def test_enter_topic_todo_read_failure_is_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failing prefill read is a clean error — before the editor. + + A directory named ``todo.md`` makes ``read_text`` raise + ``IsADirectoryError``; the boundary must fold it instead of + letting a raw traceback reach the CLI. + """ + monkeypatch.chdir(tmp_path) + (_topic_dir(tmp_path, "2026", "feature-foo") / "todo.md").mkdir() + marker = tmp_path / "editor-launched" + _editor_script(monkeypatch, tmp_path, f"touch '{marker}'") + _tty(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + enter_topic_todo("feature-foo", year="2026") + + assert "cannot read or write the todo file" in raised.value.message + assert not marker.exists() + + +# --- Infrastructure boundary --- + + +class TestCreationInfrastructureBoundary: + def test_git_failure_of_the_oracles_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A git infrastructure failure with stderr becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + failure = subprocess.CalledProcessError( + returncode=128, cmd=["git", "for-each-ref"], stderr="fatal: not a git repository" + ) + monkeypatch.setattr(creation, "list_branch_refs", mock.Mock(side_effect=failure)) + + with pytest.raises(click.ClickException) as raised: + check_branch_occupancy("feat/x", "feat-x", "2026") + + assert "fatal: not a git repository" in raised.value.message + + def test_git_failure_of_the_slug_oracle_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The branch-tree oracle wraps the listing failure like its sibling. + + Both git touchpoints of the oracle share the boundary: the inventory + listing and the per-ref tree reader. + """ + monkeypatch.chdir(tmp_path) + failure = subprocess.CalledProcessError( + returncode=128, cmd=["git", "ls-tree"], stderr="fatal: not a git repository" + ) + _wire_slug_oracle( + monkeypatch, + [BranchRef(name="alpha", remote=False)], + mock.Mock(side_effect=failure), + ) + + with pytest.raises(click.ClickException) as raised: + check_slug_occupancy("feature-foo", "2026") + + assert "fatal: not a git repository" in raised.value.message + + def test_missing_git_binary_of_the_slug_oracle_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A missing git binary is a clean error on the branch-tree oracle too.""" + monkeypatch.chdir(tmp_path) + _wire_slug_oracle( + monkeypatch, + [BranchRef(name="alpha", remote=False)], + mock.Mock(side_effect=FileNotFoundError("git")), + ) + + with pytest.raises(click.ClickException) as raised: + check_slug_occupancy("feature-foo", "2026") + + assert "git" in raised.value.message + + def test_missing_git_binary_surfaces_as_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A missing git binary is a clean error on both public entries.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(creation, "list_branch_refs", mock.Mock(side_effect=FileNotFoundError("git"))) + + with pytest.raises(click.ClickException) as raised: + check_branch_occupancy("feat/x", "feat-x", "2026") + + assert "git" in raised.value.message + + def test_create_mutation_failure_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failing branch plant of the switch path becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + _wire_creation(monkeypatch, current="main") + failure = subprocess.CalledProcessError( + returncode=128, + cmd=["git", "branch", "feat/x"], + stderr="fatal: invalid branch name", + ) + monkeypatch.setattr(creation, "create_branch_at_commit", mock.Mock(side_effect=failure)) + + with pytest.raises(click.ClickException) as raised: + create_topic("feat/x", "HEAD", todo="T", year="2026", switch=True) + + assert "fatal: invalid branch name" in raised.value.message + assert not (tmp_path / ".goga" / "history" / "2026" / "feat-x").exists() + + def test_create_no_switch_plant_failure_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failing quarantined plant of the default path becomes a ``ClickException``. + + The failure crosses the publishing module — the boundary still + wraps it, and no working-copy artifact appears. + """ + monkeypatch.chdir(tmp_path) + real_plant = publishing._plant_topic_branch + _wire_creation(monkeypatch, current="main") + monkeypatch.setattr(publishing, "_plant_topic_branch", real_plant) + monkeypatch.setattr(publishing, "commit_file_on_base", mock.Mock(return_value="beef00")) + failure = subprocess.CalledProcessError( + returncode=128, + cmd=["git", "update-ref", "--stdin", "-z"], + stderr="fatal: reference already exists", + ) + monkeypatch.setattr(publishing, "create_branch_at_commit", mock.Mock(side_effect=failure)) + + with pytest.raises(click.ClickException) as raised: + create_topic("feat/x", "HEAD", todo="T", year="2026") + + assert "reference already exists" in raised.value.message + assert not (tmp_path / ".goga" / "history" / "2026" / "feat-x").exists() + + def test_missing_git_binary_at_creation_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A missing git binary during the create mutation is a clean error.""" + monkeypatch.chdir(tmp_path) + _wire_creation(monkeypatch, current="main") + monkeypatch.setattr( + creation, + "create_branch_at_commit", + mock.Mock(side_effect=FileNotFoundError("git")), + ) + + with pytest.raises(click.ClickException) as raised: + create_topic("feat/x", "HEAD", todo="T", year="2026", switch=True) + + assert "git" in raised.value.message + + def test_stray_file_at_topic_path_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A stray file named like the slug occupies no topic — the mkdir failure is a clean error. + + The history oracle counts directories only, so the name is free and + the branch mutations run first; ``ensure_topic_dir`` then fails on + the file, and the boundary turns the ``OSError`` into a clean error + instead of a traceback. + """ + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + year_dir.mkdir(parents=True) + (year_dir / "feat-x").write_text("not a topic", encoding="utf-8") + wired = _wire_creation(monkeypatch, current="main") + + with pytest.raises(click.ClickException) as raised: + create_topic("feat-x", "HEAD", todo="T", year="2026", switch=True) + + assert "cannot create the topic directory or write the todo file" in raised.value.message + assert "feat-x" in raised.value.message + # The traced order — the branch mutations run before the directory. + wired.create_branch.assert_called_once_with("feat-x", "c0ffee") + wired.checkout.assert_called_once_with("feat-x") diff --git a/tests/topics/test_deletion.py b/tests/topics/test_deletion.py new file mode 100644 index 00000000..f15f9e0c --- /dev/null +++ b/tests/topics/test_deletion.py @@ -0,0 +1,807 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/CODEMANIFEST`` with ``location: deletion.py``: + +- ``DeleteTarget(topic, branch, remote, has_dir)`` — one identified + deletion target +- ``resolve_delete_targets(identifiers, year)`` — the read-only resolution +- ``delete_topics(targets, year)`` — the confirmed removal + +The git boundary is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched: the inventory, +the ref-tree reading, the current branch, and the removal primitives are +patched at ``goga.topics.deletion``. The disk tree is real on ``tmp_path`` +via ``monkeypatch.chdir`` — ``collect_history_tree`` and (where the +scenario says so) ``remove_topic_dir`` run against it. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import subprocess +import typing +from collections.abc import Callable +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +import click +import pytest +from goga.history import remove_topic_dir as _remove_topic_dir +from goga.topics import DeleteTarget, delete_topics, deletion, resolve_delete_targets +from goga.topics.git import BranchRef + +from tests.conftest import is_kw_only_dataclass + +# --- Shared scenario helpers --- + + +def _trees_reader(trees: dict[str, list[str]]) -> Callable[..., list[str]]: + """A ``read_ref_tree_paths`` stand-in answering by ref display name.""" + + def read(ref: str, prefix: str) -> list[str]: + assert prefix == ".goga/history/2026/", "the resolution reads under the year prefix only" + return [path for path in trees.get(ref, []) if path.startswith(prefix)] + + return read + + +def _wire_resolution( + monkeypatch: pytest.MonkeyPatch, + inventory: list[BranchRef], + trees: dict[str, list[str]], + current: str | None, +) -> None: + """Patch the resolution's import points: git inventory, trees, branch.""" + monkeypatch.setattr(deletion, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(deletion, "resolve_current_branch_name", lambda: current) + monkeypatch.setattr(deletion, "read_ref_tree_paths", _trees_reader(trees)) + + +def _disk_topic(cwd: Path, year: str, slug: str) -> None: + """Create the on-disk topic directory of the year.""" + (cwd / ".goga" / "history" / year / slug).mkdir(parents=True, exist_ok=True) + + +def _twin_inventory() -> list[BranchRef]: + """The design-scenario inventory: a local branch and its remote twin.""" + return [ + BranchRef(name="feature-foo", remote=False), + BranchRef(name="origin/feature-foo", remote=True), + ] + + +def _twin_trees() -> dict[str, list[str]]: + """The design-scenario ref trees: one topic on both refs.""" + return { + "feature-foo": [".goga/history/2026/feature-foo/plan.md"], + "origin/feature-foo": [".goga/history/2026/feature-foo/plan.md"], + } + + +def _wire_removal( + monkeypatch: pytest.MonkeyPatch, + *, + commit: str = "c123", + remote_error: Exception | None = None, + restore_error: Exception | None = None, + dir_side_effect: Callable[..., bool] | None = None, +) -> SimpleNamespace: + """Patch the removal's import points with recording mocks. + + Every removal primitive of ``deletion`` is replaced by a mock; the + mocks share one parent so ``wired.order.mock_calls`` records the + cross-primitive call order. ``dir_side_effect`` lets the directory + removal run the real history-facade function against ``tmp_path``. + + Args: + monkeypatch: The patcher scoping the mocks to one test. + commit: The commit the capture mock resolves. + remote_error: The failure the remote deletion raises, if any. + restore_error: The failure the restore (branch re-plant) raises. + dir_side_effect: The real behavior of the directory removal. + + Returns: + The recording mocks: ``order`` (the shared parent), ``capture``, + ``local``, ``remote``, ``restore``, and ``directory``. + """ + order = mock.Mock() + capture = mock.Mock(return_value=commit) + local = mock.Mock() + remote = mock.Mock(side_effect=remote_error) + restore = mock.Mock(side_effect=restore_error) + directory = mock.Mock(return_value=False, side_effect=dir_side_effect) + + for name, child in ( + ("capture", capture), + ("local", local), + ("remote", remote), + ("restore", restore), + ("directory", directory), + ): + order.attach_mock(child, name) + monkeypatch.setattr(deletion, "resolve_ref_commit", capture) + monkeypatch.setattr(deletion, "delete_local_branch", local) + monkeypatch.setattr(deletion, "delete_remote_branch", remote) + monkeypatch.setattr(deletion, "create_branch_at_commit", restore) + monkeypatch.setattr(deletion, "remove_topic_dir", directory) + return SimpleNamespace( + order=order, capture=capture, local=local, remote=remote, restore=restore, directory=directory + ) + + +# --- Contract tests --- + + +class TestDeletionContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """``DeleteTarget`` and the resolver live on the cell facade.""" + import goga.topics as cell + + assert cell.DeleteTarget is DeleteTarget + assert cell.resolve_delete_targets is resolve_delete_targets + for name in ("DeleteTarget", "resolve_delete_targets"): + assert name in cell.__all__ + + def test_delete_target_is_a_frozen_kw_only_dataclass(self) -> None: + """``@dataclass(frozen=True, kw_only=True)`` with the four declared fields.""" + assert dataclasses.is_dataclass(DeleteTarget) + assert DeleteTarget.__dataclass_params__.frozen is True + assert is_kw_only_dataclass(DeleteTarget) + assert typing.get_type_hints(DeleteTarget) == { + "topic": str, + "branch": str | None, + "remote": str | None, + "has_dir": bool, + } + target = DeleteTarget(topic="x", branch=None, remote=None, has_dir=True) + assert target.topic == "x" + assert target.branch is None + assert target.remote is None + assert target.has_dir is True + with pytest.raises(dataclasses.FrozenInstanceError): + target.topic = "other" # type: ignore[misc] + with pytest.raises(TypeError): + DeleteTarget("x", None, None, True) # type: ignore[misc] + + def test_resolve_delete_targets_signature(self) -> None: + """``resolve_delete_targets(identifiers, year=None) -> list[...]``.""" + signature = inspect.signature(resolve_delete_targets) + assert list(signature.parameters) == ["identifiers", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + assert typing.get_type_hints(resolve_delete_targets) == { + "identifiers": list[str], + "year": str | None, + "return": list[DeleteTarget], + } + + def test_delete_topics_is_importable_from_the_cell_facade(self) -> None: + """``delete_topics`` lives on the cell facade.""" + import goga.topics as cell + + assert cell.delete_topics is delete_topics + assert "delete_topics" in cell.__all__ + + def test_delete_topics_signature(self) -> None: + """``delete_topics(targets, year=None) -> str``.""" + signature = inspect.signature(delete_topics) + assert list(signature.parameters) == ["targets", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + assert typing.get_type_hints(delete_topics) == { + "targets": list[DeleteTarget], + "year": str | None, + "return": str, + } + + +# --- Logic tests: the resolution tiers --- + + +class TestResolveDeleteTargets: + def test_resolve_delete_targets_exact_slug_with_unhosted_disk_topic( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A disk topic no branch hosts is a target — directory only.""" + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "orphan-topic") + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="origin/other", remote=True), + ] + _wire_resolution(monkeypatch, inventory, {"main": []}, "main") + + targets = resolve_delete_targets(["orphan-topic"], year="2026") + + assert targets == [DeleteTarget(topic="orphan-topic", branch=None, remote=None, has_dir=True)] + + def test_resolve_delete_targets_collapses_local_and_origin_twin( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The branch name and its twin name identify one target with both refs.""" + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "feature-foo") + _wire_resolution(monkeypatch, _twin_inventory(), _twin_trees(), "main") + + targets = resolve_delete_targets(["feature-foo", "origin/feature-foo"], year="2026") + + assert targets == [DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True)] + + def test_resolve_delete_targets_twin_collapse_order_independent( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Both identifier orders return the identical single target (fix D8).""" + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "feature-foo") + _wire_resolution(monkeypatch, _twin_inventory(), _twin_trees(), "main") + + twin_first = resolve_delete_targets(["origin/feature-foo", "feature-foo"], year="2026") + local_first = resolve_delete_targets(["feature-foo", "origin/feature-foo"], year="2026") + + expected = [DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True)] + assert twin_first == expected + assert local_first == expected + + def test_resolve_delete_targets_ambiguous_error_all_or_nothing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Several topics in the winning tier cancel the whole call.""" + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "orphan-topic") + inventory = [BranchRef(name="feature-foo", remote=False)] + trees = { + "feature-foo": [ + ".goga/history/2026/feature-foo/plan.md", + ".goga/history/2026/feature-foobar/plan.md", + ] + } + _wire_resolution(monkeypatch, inventory, trees, "main") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-foo", "orphan-topic"], year="2026") + + assert "feature-foo" in raised.value.message + assert "feature-foobar" in raised.value.message + + def test_resolve_delete_targets_current_branch_guard(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A target hosted by the current branch is a clean error — switch away.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="feature-foo", remote=False)] + trees = {"feature-foo": [".goga/history/2026/feature-foo/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "feature-foo") + + with pytest.raises(click.ClickException, match="switch"): + resolve_delete_targets(["feature-foo"], year="2026") + + def test_resolve_delete_targets_merged_topic_is_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A topic hosted only by integration refs is merged work, not a target (fix D3).""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="origin/main", remote=True), + ] + trees = { + "main": [".goga/history/2026/feature-x/plan.md"], + "origin/main": [".goga/history/2026/feature-x/plan.md"], + } + _wire_resolution(monkeypatch, inventory, trees, "other") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-x"], year="2026") + + assert "feature-x" in raised.value.message + assert "main" in raised.value.message + + def test_resolve_delete_targets_slug_tier_names_one_topic_of_multi_topic_host( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The exact-slug tier names one topic — never the host's other topics. + + An integration branch accumulates merged topics by design; deleting + one by slug must reach the merged-work guard naming the hosting + branch, not an ambiguity listing every topic the branch hosts. + """ + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="origin/main", remote=True), + ] + trees = { + "main": [ + ".goga/history/2026/feature-a/plan.md", + ".goga/history/2026/feature-b/plan.md", + ], + "origin/main": [ + ".goga/history/2026/feature-a/plan.md", + ".goga/history/2026/feature-b/plan.md", + ], + } + _wire_resolution(monkeypatch, inventory, trees, "other") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-b"], year="2026") + + assert "merged work" in raised.value.message + assert "feature-b" in raised.value.message + assert "main" in raised.value.message + + def test_resolve_delete_targets_integration_branch_named_directly_is_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The merged-work guard applies through the exact-branch tier too (fix D3).""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": [".goga/history/2026/cleanup/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "other") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["main"], year="2026") + + assert "main" in raised.value.message + assert "cleanup" in raised.value.message + + def test_resolve_delete_targets_branch_without_topic_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A bare branch hosting nothing resolves to no topic — deletion deletes topics.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="gh-pages", remote=False)] + _wire_resolution(monkeypatch, inventory, {}, "main") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["gh-pages"], year="2026") + + assert "gh-pages" in raised.value.message + + def test_resolve_delete_targets_exact_name_falls_through_bare_branch_to_disk_topic( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The exact name of a bare branch does not shadow the slug tier. + + Right after a creation the branch exists while its todo.md is + still uncommitted — the topic lives on disk only, so the + exact-name identifier must reach it there, exactly as a prefix + identifier does. + """ + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "feature-foo") + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="feature-foo", remote=False), + ] + _wire_resolution(monkeypatch, inventory, {}, "main") + + targets = resolve_delete_targets(["feature-foo"], year="2026") + + assert targets == [DeleteTarget(topic="feature-foo", branch=None, remote=None, has_dir=True)] + + def test_resolve_delete_targets_bare_branch_shadow_reaches_merged_work_guard( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A bare same-named branch must not mask the merged-work truth. + + The exact name of the bare branch falls through to the slug tier, + which finds the topic hosted by another branch — the merged-work + guard names it instead of the misleading no-match error. + """ + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feature-x", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"main": [".goga/history/2026/feature-x/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "other") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-x"], year="2026") + + assert "merged work" in raised.value.message + assert "main" in raised.value.message + + def test_resolve_delete_targets_no_match_names_identifier( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An identifier nothing matches is a clean error naming it.""" + monkeypatch.chdir(tmp_path) + _wire_resolution(monkeypatch, [], {}, "main") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["nope"], year="2026") + + assert "nope" in raised.value.message + + def test_resolve_delete_targets_prefix_tier_matches_branch_and_slug( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A prefix identifier resolves through the third tier — one target. + + Both prefix arms agree here: the branch name starts with the raw + identifier and the hosted slug starts with its normalization. + """ + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="feature-foo", remote=False)] + trees = {"feature-foo": [".goga/history/2026/feature-foo/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "main") + + targets = resolve_delete_targets(["feat"], year="2026") + + assert targets == [DeleteTarget(topic="feature-foo", branch="feature-foo", remote=None, has_dir=False)] + + def test_resolve_delete_targets_prefix_of_remote_short_name_matches( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The prefix tier also reads a remote-tracking ref by its short name. + + The display name starts with ``origin/``, so only the short-name + prefix arm can match a bare identifier prefix. + """ + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="origin/feature-foo", remote=True)] + trees = {"origin/feature-foo": [".goga/history/2026/feature-foo/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "main") + + targets = resolve_delete_targets(["feature-fo"], year="2026") + + assert targets == [DeleteTarget(topic="feature-foo", branch=None, remote="feature-foo", has_dir=False)] + + def test_resolve_delete_targets_local_short_name_prefix_does_not_match( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A local slashed branch is read by its full name, never its tail. + + The short-name arm is the exact tier's remote rule: ``Foo`` is a + prefix of neither ``Feature/Foo_Bar`` nor its topic slug, so the + identifier resolves to nothing — the branch stays reachable only + through its full name or the slug prefix. + """ + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="Feature/Foo_Bar", remote=False)] + trees = {"Feature/Foo_Bar": [".goga/history/2026/feature-foo-bar/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "main") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["Foo"], year="2026") + + assert "no topic matches 'Foo'" in raised.value.message + + def test_resolve_delete_targets_non_ascii_identifier_matches_nothing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A non-ASCII identifier normalizes to the empty slug — every slug + starts with it, so the slug-prefix arms must stay disabled.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="feature-foo", remote=False)] + trees = {"feature-foo": [".goga/history/2026/feature-foo/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "main") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["Тема"], year="2026") + + assert "Тема" in raised.value.message + + def test_resolve_delete_targets_non_origin_remote_is_not_the_twin( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A tracking ref of another remote contributes no deletable twin. + + The deletion push of the git cell is origin-only, so a non-origin + remote's short name must never reach ``remote`` — it would be + pushed at origin (a wrong remote's branch deleted, or a phantom + "remote ref does not exist" after the local branch is gone). The + ref stays an eligible host — the topic is not merged work. + """ + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "feature-x") + inventory = [BranchRef(name="upstream/feature-x", remote=True)] + trees = {"upstream/feature-x": [".goga/history/2026/feature-x/plan.md"]} + _wire_resolution(monkeypatch, inventory, trees, "main") + + targets = resolve_delete_targets(["feature-x"], year="2026") + + assert targets == [DeleteTarget(topic="feature-x", branch=None, remote=None, has_dir=True)] + + def test_resolve_delete_targets_prefers_the_origin_twin_among_remotes( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """With twins on several remotes, the origin twin is the deletable one.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="origin/feature-x", remote=True), + BranchRef(name="upstream/feature-x", remote=True), + ] + trees = { + "origin/feature-x": [".goga/history/2026/feature-x/plan.md"], + "upstream/feature-x": [".goga/history/2026/feature-x/plan.md"], + } + _wire_resolution(monkeypatch, inventory, trees, "main") + + targets = resolve_delete_targets(["feature-x"], year="2026") + + assert targets == [DeleteTarget(topic="feature-x", branch=None, remote="feature-x", has_dir=False)] + + def test_resolve_delete_targets_current_branch_guard_slug_arm( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The guard also fires when the current branch's slug names the topic. + + A disk-only target carries ``branch=None``, so the branch-equality + arm alone would let ``goga topics delete feature-foo`` remove the + directory while the user sits on ``Feature_Foo``. + """ + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "feature-foo") + _wire_resolution(monkeypatch, [], {}, "Feature_Foo") + + with pytest.raises(click.ClickException, match="switch"): + resolve_delete_targets(["feature-foo"], year="2026") + + def test_resolve_delete_targets_merged_host_keeps_the_directory( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A topic still carried by a merged-work host keeps its directory. + + The own branch goes, the twin goes, the working-copy directory + stays: the merged host's tree still carries the topic, and + removing the directory would dirty its checkout while the board + keeps showing the topic. + """ + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "feature-foo") + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="origin/main", remote=True), + BranchRef(name="feature-foo", remote=False), + BranchRef(name="origin/feature-foo", remote=True), + ] + trees = { + "main": [".goga/history/2026/feature-foo/plan.md"], + "origin/main": [".goga/history/2026/feature-foo/plan.md"], + "feature-foo": [".goga/history/2026/feature-foo/plan.md"], + "origin/feature-foo": [".goga/history/2026/feature-foo/plan.md"], + } + _wire_resolution(monkeypatch, inventory, trees, "main") + + targets = resolve_delete_targets(["feature-foo"], year="2026") + + assert targets == [DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=False)] + + def test_resolve_delete_targets_several_same_slug_branches_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Two local branches normalizing into one slug never pick one of them. + + ``Feature/Foo`` and ``feature-foo`` both normalize to + ``feature-foo``; naming one of them exactly must not assemble a + target that deletes the other by inventory order. + """ + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="Feature/Foo", remote=False), + BranchRef(name="feature-foo", remote=False), + ] + trees = { + "Feature/Foo": [".goga/history/2026/feature-foo/plan.md"], + "feature-foo": [".goga/history/2026/feature-foo/plan.md"], + } + _wire_resolution(monkeypatch, inventory, trees, "main") + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-foo"], year="2026") + + assert "several branches" in raised.value.message + assert "Feature/Foo" in raised.value.message + assert "feature-foo" in raised.value.message + + +# --- Infrastructure boundary --- + + +class TestDeletionInfrastructureBoundary: + def test_git_failure_surfaces_as_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A git infrastructure failure with stderr becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + failure = subprocess.CalledProcessError( + returncode=128, cmd=["git", "for-each-ref"], stderr="fatal: not a git repository" + ) + monkeypatch.setattr(deletion, "list_branch_refs", mock.Mock(side_effect=failure)) + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-foo"], year="2026") + + assert "fatal: not a git repository" in raised.value.message + + def test_missing_git_binary_surfaces_as_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A missing git binary during the resolution is a clean error.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(deletion, "list_branch_refs", mock.Mock(side_effect=FileNotFoundError("git"))) + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-foo"], year="2026") + + assert "git is not available" in raised.value.message + + def test_history_tree_read_failure_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An OS failure of the history-tree read becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + _wire_resolution(monkeypatch, _twin_inventory(), _twin_trees(), None) + monkeypatch.setattr(deletion, "collect_history_tree", mock.Mock(side_effect=OSError("history tree unreadable"))) + + with pytest.raises(click.ClickException) as raised: + resolve_delete_targets(["feature-foo"], year="2026") + + assert "reading the history tree failed" in raised.value.message + assert "history tree unreadable" in raised.value.message + + def test_detached_head_skips_the_current_branch_guard( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No current branch (a detached HEAD): the guard passes silently + and the resolution completes.""" + monkeypatch.chdir(tmp_path) + _disk_topic(tmp_path, "2026", "feature-foo") + _wire_resolution(monkeypatch, _twin_inventory(), _twin_trees(), None) + + targets = resolve_delete_targets(["feature-foo"], year="2026") + + assert targets == [DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True)] + + +# --- Logic tests: the confirmed removal --- + + +class TestDeleteTopics: + def test_delete_topics_restores_local_on_remote_failure( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failed remote deletion restores the local branch at the captured commit.""" + monkeypatch.chdir(tmp_path) + target = DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) + wired = _wire_removal( + monkeypatch, + remote_error=subprocess.CalledProcessError(128, "git push", stderr=b"remote error"), + ) + + with pytest.raises(click.ClickException) as raised: + delete_topics([target], year="2026") + + assert "remote error" in raised.value.message + wired.restore.assert_called_once_with("feature-foo", "c123") + wired.directory.assert_not_called() + + def test_delete_topics_full_success_removes_all_three( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The local branch, the origin twin, and the directory go — in that order.""" + monkeypatch.chdir(tmp_path) + topic_dir = tmp_path / ".goga" / "history" / "2026" / "feature-foo" + topic_dir.mkdir(parents=True) + target = DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) + wired = _wire_removal(monkeypatch, dir_side_effect=_remove_topic_dir) + + line = delete_topics([target], year="2026") + + assert line == "Deleted 1 topic(s) of 2026: feature-foo" + assert not topic_dir.exists() + assert wired.order.mock_calls == [ + mock.call.capture("feature-foo"), + mock.call.local("feature-foo"), + mock.call.remote("feature-foo"), + mock.call.directory("feature-foo", "2026"), + ] + + def test_delete_topics_remote_only_target_no_restore(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A remote-only target has nothing to restore — the error propagates directly.""" + monkeypatch.chdir(tmp_path) + target = DeleteTarget(topic="ghost", branch=None, remote="ghost", has_dir=False) + wired = _wire_removal( + monkeypatch, + remote_error=subprocess.CalledProcessError(128, "git push", stderr=b"deny"), + ) + + with pytest.raises(click.ClickException) as raised: + delete_topics([target], year="2026") + + assert "deny" in raised.value.message + wired.restore.assert_not_called() + + def test_delete_topics_idempotent_directory_absence(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An already-absent directory is a False, not an error — the topic still reports.""" + monkeypatch.chdir(tmp_path) + target = DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) + wired = _wire_removal(monkeypatch, dir_side_effect=_remove_topic_dir) + + line = delete_topics([target], year="2026") + + assert line == "Deleted 1 topic(s) of 2026: feature-foo" + wired.directory.assert_called_once_with("feature-foo", "2026") + + def test_delete_topics_restore_failure_surfaces_original_reason( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A broken rollback's failure is suppressed — the remote reason surfaces.""" + monkeypatch.chdir(tmp_path) + target = DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) + _wire_removal( + monkeypatch, + remote_error=subprocess.CalledProcessError(128, "git push", stderr=b"remote error"), + restore_error=subprocess.CalledProcessError(1, "git update-ref", stderr=b"ref lock"), + ) + + with pytest.raises(click.ClickException) as raised: + delete_topics([target], year="2026") + + assert "remote error" in raised.value.message + assert "ref lock" not in raised.value.message + + def test_delete_topics_targets_before_a_failure_stay_removed( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A multi-target deletion is not all-or-nothing across targets. + + The first target is fully removed before the second's remote + deletion fails; the failing target restores its local branch at + the captured commit and never reaches its directory. + """ + monkeypatch.chdir(tmp_path) + first = DeleteTarget(topic="feature-foo", branch="feature-foo", remote="feature-foo", has_dir=True) + second = DeleteTarget(topic="feature-bar", branch="feature-bar", remote="feature-bar", has_dir=True) + wired = _wire_removal(monkeypatch) + wired.remote.side_effect = [ + None, + subprocess.CalledProcessError(128, "git push", stderr=b"deny second"), + ] + + with pytest.raises(click.ClickException, match="deny second"): + delete_topics([first, second], year="2026") + + assert wired.order.mock_calls == [ + mock.call.capture("feature-foo"), + mock.call.local("feature-foo"), + mock.call.remote("feature-foo"), + mock.call.directory("feature-foo", "2026"), + mock.call.capture("feature-bar"), + mock.call.local("feature-bar"), + mock.call.remote("feature-bar"), + mock.call.restore("feature-bar", "c123"), + ] + + def test_delete_topics_missing_git_binary_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A missing git binary during the removal is a clean error.""" + monkeypatch.chdir(tmp_path) + target = DeleteTarget(topic="feature-foo", branch="feature-foo", remote=None, has_dir=False) + monkeypatch.setattr(deletion, "resolve_ref_commit", mock.Mock(return_value="c123")) + monkeypatch.setattr(deletion, "delete_local_branch", mock.Mock(side_effect=FileNotFoundError("git"))) + + with pytest.raises(click.ClickException) as raised: + delete_topics([target], year="2026") + + assert "git is not available" in raised.value.message + + def test_delete_topics_removal_oserror_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An OS failure of the directory removal is a clean error.""" + monkeypatch.chdir(tmp_path) + target = DeleteTarget(topic="feature-foo", branch=None, remote=None, has_dir=True) + + def _boom(*args: object, **kwargs: object) -> bool: + raise OSError("disk full") + + _wire_removal(monkeypatch, dir_side_effect=_boom) + + with pytest.raises(click.ClickException) as raised: + delete_topics([target], year="2026") + + assert "cannot complete the deletion" in raised.value.message + assert "disk full" in raised.value.message diff --git a/tests/topics/test_ensuring.py b/tests/topics/test_ensuring.py new file mode 100644 index 00000000..967dd767 --- /dev/null +++ b/tests/topics/test_ensuring.py @@ -0,0 +1,640 @@ +"""Contract and logic tests for the entity declared in +``goga/topics/CODEMANIFEST`` with ``location: ensuring.py``: + +- ``ensure_topic(identifier, todo, year)`` — the switch-or-create orchestration + +The git boundary is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched. The resolution runs +real through the switching module's patched import points where the scenario +walks the inventory and is mocked at ``goga.topics.ensuring``'s import point +where the scenario pins an exact candidate list; the switch tail is +``switch_topic`` mocked at its import point in ``ensuring`` (its own +orchestration is the switching suite's concern); the fast creation mocks the +occupancy oracles, ``create_and_switch_branch``, and the todo entry at their +import points in ``ensuring`` — with the topic-directory creation real on a +``tmp_path`` tree where the design says so. The scale is the +``builtin_scale`` fixture. +""" + +from __future__ import annotations + +import inspect +import subprocess +import sys +import typing +from collections.abc import Callable +from pathlib import Path +from unittest import mock + +import click +import pytest +from goga.history import current_year +from goga.history.statuses import StatusScale +from goga.topics import SwitchCandidate, board, ensure_topic, ensuring, switching +from goga.topics.git import BranchRef + +# --- Shared scenario helpers --- + + +def _trees_reader(trees: dict[str, list[str]]) -> Callable[..., list[str]]: + """A ``read_ref_tree_paths`` stand-in answering by ref display name.""" + + def read(ref: str, prefix: str) -> list[str]: + assert prefix == ".goga/history/", "the resolution reads under the history root only" + return [path for path in trees.get(ref, []) if path.startswith(prefix)] + + return read + + +def _wire_resolution( + monkeypatch: pytest.MonkeyPatch, + scale: StatusScale, + inventory: list[BranchRef], + trees: dict[str, list[str]], + current: str | None, +) -> None: + """Patch the resolution's import points: scale, git inventory, trees, branch.""" + monkeypatch.setattr(switching, "assemble_status_scale", lambda: scale) + monkeypatch.setattr(switching, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(switching, "resolve_current_branch_name", lambda: current) + monkeypatch.setattr(board, "read_ref_tree_paths", _trees_reader(trees)) + + +def _wire_resolver(monkeypatch: pytest.MonkeyPatch, candidates: list[SwitchCandidate]) -> mock.Mock: + """Patch the resolution at its import point in ``ensuring``. + + Returns: + The resolver as a recording mock answering the pinned candidates. + """ + resolver = mock.Mock(return_value=candidates) + monkeypatch.setattr(ensuring, "resolve_switch_candidates", resolver) + return resolver + + +def _wire_mutations(monkeypatch: pytest.MonkeyPatch, clean: bool = True) -> tuple[mock.Mock, mock.Mock, mock.Mock]: + """Patch the switch mutations at their import points in ``switching``. + + Returns: + The cleanliness probe, the local checkout, and the remote-tracking + branch creation — all as recording mocks. + """ + cleanliness = mock.Mock(return_value=clean) + checkout = mock.Mock() + remote_creation = mock.Mock() + monkeypatch.setattr(switching, "is_working_tree_clean", cleanliness) + monkeypatch.setattr(switching, "checkout_local_branch", checkout) + monkeypatch.setattr(switching, "create_branch_from_remote_tracking", remote_creation) + return cleanliness, checkout, remote_creation + + +def _wire_switch(monkeypatch: pytest.MonkeyPatch, line: str) -> mock.Mock: + """Patch the switch orchestration at its import point in ``ensuring``. + + Returns: + ``switch_topic`` as a recording mock answering the given result line. + """ + switch = mock.Mock(return_value=line) + monkeypatch.setattr(ensuring, "switch_topic", switch) + return switch + + +def _wire_fast_creation( + monkeypatch: pytest.MonkeyPatch, + occupied: str | None = None, + real_dir: bool = False, +) -> tuple[mock.Mock, mock.Mock | None]: + """Patch the fast-creation boundary at ``ensuring``'s import points. + + Args: + monkeypatch: The patch fixture. + occupied: The branch-oracle answer — a conflict reason or ``None``. + real_dir: Keep ``ensure_topic_dir`` real (the on-disk scenarios). + + Returns: + The create-and-switch mutation and the topic-directory creation as + recording mocks — the directory-creation mock is ``None`` when + ``real_dir`` left the real routine in place. + """ + monkeypatch.setattr(ensuring, "check_branch_occupancy", mock.Mock(return_value=occupied)) + monkeypatch.setattr(ensuring, "check_slug_occupancy", mock.Mock(return_value=None)) + create_and_switch = mock.Mock() + monkeypatch.setattr(ensuring, "create_and_switch_branch", create_and_switch) + + if real_dir: + return create_and_switch, None + + ensure_dir = mock.Mock() + monkeypatch.setattr(ensuring, "ensure_topic_dir", ensure_dir) + return create_and_switch, ensure_dir + + +def _wire_current(monkeypatch: pytest.MonkeyPatch, current: str | None) -> mock.Mock: + """Patch the current-branch read at its import point in ``ensuring``. + + Returns: + The reader as a recording mock answering the given branch name. + """ + resolver = mock.Mock(return_value=current) + monkeypatch.setattr(ensuring, "resolve_current_branch_name", resolver) + return resolver + + +def _wire_entry(monkeypatch: pytest.MonkeyPatch) -> mock.Mock: + """Patch the todo entry at its import point in ``ensuring``. + + Returns: + ``enter_topic_todo`` as a recording mock. + """ + entry = mock.Mock() + monkeypatch.setattr(ensuring, "enter_topic_todo", entry) + return entry + + +def _non_interactive(monkeypatch: pytest.MonkeyPatch) -> None: + """Make stdin a non-terminal — the todo entry must abort cleanly.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + +def _interactive(monkeypatch: pytest.MonkeyPatch) -> None: + """Make stdin a terminal — the todo entry of the ensure is reachable.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + + +def _working_copy_topic(cwd: Path, year: str, slug: str, artifacts: list[str]) -> None: + """Create the working-copy topic directory with its artifact files.""" + for artifact in artifacts: + path = cwd / ".goga" / "history" / year / slug / artifact + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("artifact", encoding="utf-8") + + +def _twin_inventory() -> list[BranchRef]: + """The design-scenario inventory: a local branch and its remote twin.""" + return [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/feat/a", remote=True), + ] + + +def _twin_trees() -> dict[str, list[str]]: + """The design-scenario ref trees: one planned topic on both refs.""" + return { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "origin/feat/a": [".goga/history/2026/feat-a/plan.md"], + } + + +# --- Contract tests --- + + +class TestEnsuringContract: + def test_ensure_topic_is_importable_from_the_cell_facade(self) -> None: + """``ensure_topic`` lives on the cell facade.""" + import goga.topics as cell + + assert cell.ensure_topic is ensure_topic + assert "ensure_topic" in cell.__all__ + + def test_ensure_topic_signature(self) -> None: + """``ensure_topic(identifier, todo=False, year=None) -> str``.""" + signature = inspect.signature(ensure_topic) + assert list(signature.parameters) == ["identifier", "todo", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["todo"].default is False + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(ensure_topic) + assert hints == {"identifier": str, "todo": bool, "year": str | None, "return": str} + + def test_ensure_topic_single_argument_call_still_binds(self) -> None: + """The pipeline caller ``ensure_topic(topic)`` stays compatible.""" + inspect.signature(ensure_topic).bind("history-com") + + +# --- Logic tests: the fast creation at zero candidates --- + + +class TestEnsureTopicFastCreation: + def test_ensure_topic_fast_creation_from_current_head( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Nothing hosts the identifier: the branch named as entered comes + off the current HEAD, then the topic directory, then — under + ``todo`` — the entry; the line carries the name as entered and the + normalized slug.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + create_and_switch, ensure_dir = _wire_fast_creation(monkeypatch) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + order = mock.Mock() + order.attach_mock(create_and_switch, "create_and_switch") + order.attach_mock(ensure_dir, "ensure_topic_dir") + order.attach_mock(entry, "entry") + + result = ensure_topic("Feature/Foo_Bar", todo=True, year="2026") + + assert result == "Created branch Feature/Foo_Bar and topic 2026/feature-foo-bar" + create_and_switch.assert_called_once_with("Feature/Foo_Bar") + ensure_dir.assert_called_once_with("Feature/Foo_Bar", "2026") + entry.assert_called_once_with("Feature/Foo_Bar", "2026") + assert order.mock_calls == [ + mock.call.create_and_switch("Feature/Foo_Bar"), + mock.call.ensure_topic_dir("Feature/Foo_Bar", "2026"), + mock.call.entry("Feature/Foo_Bar", "2026"), + ] + # The fast creation is local-only and asks nothing: the publication + # primitives have no place here, and the old create_topic delegation + # must not leak back in. + assert not hasattr(ensuring, "resolve_ref_commit") + assert not hasattr(ensuring, "push_branch") + assert not hasattr(ensuring, "create_topic") + + def test_ensure_topic_fast_creation_writes_the_real_topic_directory( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The fast creation creates the real topic directory of the year — + and without ``todo`` no entry runs (no terminal needed).""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": [".goga/history/2026/other/prd.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + create_and_switch, _ensure_dir = _wire_fast_creation(monkeypatch, real_dir=True) + entry = _wire_entry(monkeypatch) + + result = ensure_topic("prune-history-and-new-status", year="2026") + + assert result == ("Created branch prune-history-and-new-status and topic 2026/prune-history-and-new-status") + create_and_switch.assert_called_once_with("prune-history-and-new-status") + entry.assert_not_called() + assert (tmp_path / ".goga" / "history" / "2026" / "prune-history-and-new-status").is_dir() + + def test_ensure_topic_occupied_name_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An occupancy conflict at zero candidates: a clean error carrying + the reason and the board hint — nothing created.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + create_and_switch, ensure_dir = _wire_fast_creation(monkeypatch, occupied="branch 'x' exists") + + with pytest.raises(click.ClickException) as raised: + ensure_topic("x", year="2026") + + assert raised.value.message == "branch 'x' exists — run 'goga topics board' to see the board" + create_and_switch.assert_not_called() + ensure_dir.assert_not_called() + + def test_ensure_topic_empty_slug_identifier_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An identifier normalizing to an empty slug is a clean error — a + name git would accept but the history tree cannot address must not + create a branch that can never host a topic directory.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + create_and_switch, ensure_dir = _wire_fast_creation(monkeypatch) + create_and_switch.side_effect = AssertionError("an empty-slug name must not create a branch") + + with pytest.raises(click.ClickException, match="empty topic slug"): + ensure_topic("???", year="2026") + + create_and_switch.assert_not_called() + ensure_dir.assert_not_called() + + def test_ensure_topic_stray_file_at_topic_path_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A stray file named like the slug folds into the same boundary + ``create_topic`` keeps — the pipeline-driven path pierces no + further than the CLI one. + + The history oracle counts directories only, so the name is free + and the branch mutation runs first; ``ensure_topic_dir`` then + fails on the file, and the wrapper turns the ``OSError`` into a + clean error instead of a traceback. + """ + monkeypatch.chdir(tmp_path) + year_dir = tmp_path / ".goga" / "history" / "2026" + year_dir.mkdir(parents=True) + (year_dir / "feat-x").write_text("not a topic", encoding="utf-8") + inventory = [BranchRef(name="main", remote=False)] + trees = {"main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + create_and_switch, _ensure_dir = _wire_fast_creation(monkeypatch, real_dir=True) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("feat-x", year="2026") + + assert "cannot create the topic directory or write the todo file" in raised.value.message + assert "feat-x" in raised.value.message + create_and_switch.assert_called_once_with("feat-x") + + +# --- Logic tests: the delegated switch at non-empty candidates --- + + +class TestEnsureTopicSwitch: + def test_ensure_topic_single_candidate_switches_without_creation( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A hosted identifier takes the delegated switch without the entry + — the fast creation never runs.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + switch = _wire_switch(monkeypatch, "Switched to branch feat/a") + create_and_switch, _ensure_dir = _wire_fast_creation(monkeypatch) + + result = ensure_topic("feat/a", year="2026") + + assert result == "Switched to branch feat/a" + switch.assert_called_once_with("feat/a", todo=False, year="2026") + create_and_switch.assert_not_called() + + def test_ensure_topic_multiple_candidates_delegates_the_choice_to_switch( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Several candidates: the delegated switch orchestration owns the + numbered choice — its non-terminal abort propagates, and ambiguity + never escapes into creation.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/ab", remote=False), + BranchRef(name="main", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/ab": [".goga/history/2026/feat-ab/plan.md"], + "main": ["README.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _cleanliness, checkout, _remote_creation = _wire_mutations(monkeypatch, clean=True) + create_and_switch, _ensure_dir = _wire_fast_creation(monkeypatch) + _non_interactive(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("feat", year="2026") + + assert "1)" in raised.value.message + assert "2)" in raised.value.message + checkout.assert_not_called() + create_and_switch.assert_not_called() + + +# --- Logic tests: the todo entry of the ensured work --- + + +class TestEnsureTopicTodo: + def test_ensure_topic_switch_branch_without_topic_creates_dir_then_enters( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``todo`` on a branch without a topic: the delegated switch runs + without the entry, then the topic directory is created and the fresh + entry follows — the fast process is never interrupted.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate(branch="feature-foo", topic=None, statuses=[], current=True, remote=False) + _wire_resolver(monkeypatch, [candidate]) + switch = _wire_switch(monkeypatch, "Already on branch feature-foo") + _wire_current(monkeypatch, "feature-foo") + ensure_dir = mock.Mock() + monkeypatch.setattr(ensuring, "ensure_topic_dir", ensure_dir) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + order = mock.Mock() + order.attach_mock(ensure_dir, "ensure_topic_dir") + order.attach_mock(entry, "entry") + + result = ensure_topic("feature-foo", todo=True, year="2026") + + assert result == "Already on branch feature-foo" + switch.assert_called_once_with("feature-foo", todo=False, year="2026") + ensure_dir.assert_called_once_with("feature-foo", "2026") + entry.assert_called_once_with("feature-foo", "2026") + assert order.mock_calls == [ + mock.call.ensure_topic_dir("feature-foo", "2026"), + mock.call.entry("feature-foo", "2026"), + ] + + def test_ensure_topic_todo_enters_resolved_topic_not_branch_slug( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The hosted topic comes from the resolution candidate — a topic + merged into another branch is entered as itself; no directory of + the hosting branch's name is ever created.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate(branch="main", topic="feature-x", statuses=["todo"], current=False, remote=False) + _wire_resolver(monkeypatch, [candidate]) + _wire_switch(monkeypatch, "Switched to branch main") + _wire_current(monkeypatch, "main") + ensure_dir = mock.Mock() + monkeypatch.setattr(ensuring, "ensure_topic_dir", ensure_dir) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + + result = ensure_topic("feature-x", todo=True, year="2026") + + assert result == "Switched to branch main" + entry.assert_called_once_with("feature-x", "2026") + ensure_dir.assert_not_called() + assert not (tmp_path / ".goga" / "history" / "2026" / "main").exists() + + def test_ensure_topic_todo_matches_remote_candidate_by_short_name( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A remote-tracking candidate matches the current branch by its + short name — the local branch the switch created.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate( + branch="origin/feature-x", topic="feature-x", statuses=["todo"], current=False, remote=True + ) + _wire_resolver(monkeypatch, [candidate]) + _wire_switch(monkeypatch, "Created branch feature-x from origin/feature-x") + _wire_current(monkeypatch, "feature-x") + ensure_dir = mock.Mock() + monkeypatch.setattr(ensuring, "ensure_topic_dir", ensure_dir) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + + result = ensure_topic("feature-x", todo=True, year="2026") + + assert result == "Created branch feature-x from origin/feature-x" + entry.assert_called_once_with("feature-x", "2026") + ensure_dir.assert_not_called() + + def test_ensure_topic_todo_empty_slug_branch_without_topic_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A no-topic branch whose name normalizes to an empty slug is a + clean error — the history facade's ``ValueError`` never escapes the + module, and nothing is created or entered.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate(branch="Тема", topic=None, statuses=[], current=False, remote=False) + _wire_resolver(monkeypatch, [candidate]) + _wire_switch(monkeypatch, "Switched to branch Тема") + _wire_current(monkeypatch, "Тема") + ensure_dir = mock.Mock() + monkeypatch.setattr(ensuring, "ensure_topic_dir", ensure_dir) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("Тема", todo=True, year="2026") + + assert raised.value.message == "branch name 'Тема' normalizes to an empty topic slug" + ensure_dir.assert_not_called() + entry.assert_not_called() + + def test_ensure_topic_todo_idempotent_enters_the_hosted_topic( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Already on the host: the delegated switch returns the idempotent + line and the entry runs for the hosted topic of the working copy.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, current_year(), "feat-a", ["plan.md"]) + _wire_resolution(monkeypatch, builtin_scale, _twin_inventory(), _twin_trees(), "feat/a") + _wire_switch(monkeypatch, "Already on branch feat/a") + _wire_current(monkeypatch, "feat/a") + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + + result = ensure_topic("feat/a", todo=True) + + assert result == "Already on branch feat/a" + entry.assert_called_once_with("feat-a", None) + + def test_ensure_topic_todo_non_tty_error_before_action( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``todo=True`` without a terminal: the clean error fires before + any action — ordering, not just the error.""" + monkeypatch.chdir(tmp_path) + _non_interactive(monkeypatch) + resolver = mock.Mock(side_effect=AssertionError("the resolution must not run")) + monkeypatch.setattr(ensuring, "resolve_switch_candidates", resolver) + + with pytest.raises(click.ClickException, match="interactive"): + ensure_topic("anything", todo=True) + + resolver.assert_not_called() + + def test_ensure_topic_todo_current_branch_matching_no_candidate_creates_directory( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The current branch matching no resolution candidate takes the + directory-creation path: the topic directory of the current branch + is created first, then the fresh entry follows.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate( + branch="other-branch", topic="other-topic", statuses=[], current=False, remote=False + ) + _wire_resolver(monkeypatch, [candidate]) + _wire_switch(monkeypatch, "Switched to branch fresh-work") + _wire_current(monkeypatch, "fresh-work") + ensure_dir = mock.Mock() + monkeypatch.setattr(ensuring, "ensure_topic_dir", ensure_dir) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + order = mock.Mock() + order.attach_mock(ensure_dir, "ensure_topic_dir") + order.attach_mock(entry, "entry") + + result = ensure_topic("fresh-work", todo=True, year="2026") + + assert result == "Switched to branch fresh-work" + ensure_dir.assert_called_once_with("fresh-work", "2026") + entry.assert_called_once_with("fresh-work", "2026") + assert order.mock_calls == [ + mock.call.ensure_topic_dir("fresh-work", "2026"), + mock.call.entry("fresh-work", "2026"), + ] + + +# --- Logic tests: the infrastructure boundary of the ensure --- + + +class TestEnsuringInfrastructureBoundary: + def test_git_failure_surfaces_as_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A raw git infrastructure failure escaping a delegate becomes a + ``ClickException`` carrying the git reason.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate( + branch="feature-foo", topic="feature-foo", statuses=["todo"], current=True, remote=False + ) + _wire_resolver(monkeypatch, [candidate]) + failure = subprocess.CalledProcessError(128, "git checkout", stderr="fatal: bad object") + monkeypatch.setattr(ensuring, "switch_topic", mock.Mock(side_effect=failure)) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("feature-foo", year="2026") + + assert "fatal: bad object" in raised.value.message + + def test_missing_git_binary_surfaces_as_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A raw missing-git-binary failure escaping a delegate is a clean + error.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate( + branch="feature-foo", topic="feature-foo", statuses=["todo"], current=True, remote=False + ) + _wire_resolver(monkeypatch, [candidate]) + monkeypatch.setattr(ensuring, "switch_topic", mock.Mock(side_effect=FileNotFoundError("git"))) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("feature-foo", year="2026") + + assert "git is not available" in raised.value.message + + def test_broken_tool_package_import_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A fatal ``ImportError`` escaping a delegate keeps its package + name in the clean error.""" + monkeypatch.chdir(tmp_path) + candidate = SwitchCandidate( + branch="feature-foo", topic="feature-foo", statuses=["todo"], current=True, remote=False + ) + _wire_resolver(monkeypatch, [candidate]) + broken = ImportError("package goga_tool_bad failed to import: boom") + monkeypatch.setattr(ensuring, "switch_topic", mock.Mock(side_effect=broken)) + + with pytest.raises(click.ClickException) as raised: + ensure_topic("feature-foo", year="2026") + + assert raised.value.message == "package goga_tool_bad failed to import: boom" diff --git a/tests/topics/test_publishing.py b/tests/topics/test_publishing.py new file mode 100644 index 00000000..89f30852 --- /dev/null +++ b/tests/topics/test_publishing.py @@ -0,0 +1,616 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/CODEMANIFEST`` with ``location: publishing.py``: + +- ``publish_topic(branch_name, todo, base_ref, commit_message, year)`` — + the fast creation-and-publication cycle + +Every git touchpoint is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched; ``normalize_topic_slug`` +stays real because it is a pure string transformation, and so does +``resolve_topic_file`` (a pure path composer). The recording doubles assert +the decision-before-mutation order, the exact delegation arguments, and the +full rollback of a failed publication. +""" + +from __future__ import annotations + +import inspect +import subprocess +import sys +import typing +from pathlib import Path +from unittest import mock + +import click +import pytest +from goga.topics import publish_topic, publishing + +# --- Shared scenario helpers --- + + +def _non_interactive(monkeypatch: pytest.MonkeyPatch) -> None: + """Make stdin a non-terminal — every conflict is a clean error.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + +def _terminal(monkeypatch: pytest.MonkeyPatch) -> mock.Mock: + """Make stdin a terminal and arm a prompt marker that fails on use. + + The re-ask cycle is abolished — a conflict on a terminal is a clean + error like anywhere else, so ``click.prompt`` must never run; the + returned marker asserts exactly that. + """ + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + prompt = mock.Mock(name="prompt-marker") + monkeypatch.setattr(click, "prompt", prompt) + return prompt + + +class _Cycle: + """Recording doubles of every mocked touchpoint of the fast cycle. + + Every double is attached to one parent recorder, so a scenario can + assert the real call order across touchpoints — not just per-touchpoint + counts. + """ + + def __init__(self) -> None: + self.recorder = mock.Mock(name="fast-cycle") + self.resolve_current_branch_name = self._attach("resolve_current_branch_name", return_value="main") + self.check_branch_occupancy = self._attach("check_branch_occupancy", return_value=None) + self.check_slug_occupancy = self._attach("check_slug_occupancy", return_value=None) + self.origin_configured = self._attach("origin_configured", return_value=True) + self.resolve_ref_commit = self._attach("resolve_ref_commit", return_value="<base>") + self.commit_file_on_base = self._attach("commit_file_on_base", return_value="<commit>") + self.create_branch_at_commit = self._attach("create_branch_at_commit") + self.push_branch = self._attach("push_branch") + self.delete_local_branch = self._attach("delete_local_branch") + self.current_year = self._attach("current_year", return_value="2026") + + def _attach(self, name: str, **kwargs: object) -> mock.Mock: + double = mock.Mock(**kwargs) + self.recorder.attach_mock(double, name) + return double + + +def _wire_cycle(monkeypatch: pytest.MonkeyPatch) -> _Cycle: + """Patch publishing's import points with the recording doubles.""" + cycle = _Cycle() + + for name, double in vars(cycle).items(): + if hasattr(publishing, name): + monkeypatch.setattr(publishing, name, double) + + return cycle + + +def _assert_no_mutation(cycle: _Cycle) -> None: + """Assert none of the three mutations of the cycle ran.""" + cycle.commit_file_on_base.assert_not_called() + cycle.create_branch_at_commit.assert_not_called() + cycle.push_branch.assert_not_called() + cycle.delete_local_branch.assert_not_called() + + +# --- Contract tests --- + + +class TestPublishingContract: + def test_publish_topic_is_importable_from_the_cell_facade(self) -> None: + """``publish_topic`` lives on the cell facade and in ``__all__``.""" + import goga.topics as cell + + assert cell.publish_topic is publish_topic + expected = { + "BoardRecord", + "DeleteTarget", + "SwitchCandidate", + "check_branch_occupancy", + "check_slug_occupancy", + "collect_topic_board", + "create_topic", + "delete_topics", + "ensure_topic", + "enter_topic_todo", + "publish_topic", + "resolve_delete_targets", + "resolve_switch_candidates", + "switch_topic", + } + assert set(cell.__all__) == expected + assert "publish_topic" in cell.__all__ + + def test_publish_topic_signature(self) -> None: + """``publish_topic(branch_name, todo, base_ref, commit_message=None, year=None)``. + + ``commit_message`` defaults to ``None`` — ``None`` applies the + built-in domain default template, so every caller (the CLI flags, + the configuration section) may omit it. ``todo`` is required and + non-empty at the call site; an empty todo is a clean error asking + for it. + """ + signature = inspect.signature(publish_topic) + assert list(signature.parameters) == [ + "branch_name", + "todo", + "base_ref", + "commit_message", + "year", + ] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["commit_message"].default is None + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(publish_topic) + assert hints == { + "branch_name": str, + "todo": str, + "base_ref": str, + "commit_message": str | None, + "year": str | None, + "return": str, + } + + def test_publish_topic_default_template_binds(self) -> None: + """The template-free call shape binds — ``commit_message=None``. + + The plan's pinned call: ``publish_topic("b", "t", "origin/main", + commit_message=None, year="2026")`` must keep binding after the + rework — the default moved into the domain, so omitting the + template is the supported call, not an error. + """ + assert inspect.signature(publish_topic).bind("b", "t", "origin/main", commit_message=None, year="2026") + + def test_no_working_copy_write_in_publishing(self) -> None: + """A shallow source guardrail against working-copy writes. + + This greps the module source for the write primitives and checks the + write helper is not imported — it cannot catch every spelling + (``os.makedirs``, ``Path.write_bytes``, a function-local import). + The real invariant is pinned end-to-end by the dirty-tree snapshot + of ``tests/integration/test_topic_workflows.py``; this guardrail only + makes the obvious regressions fail fast. + """ + assert not hasattr(publishing, "ensure_topic_dir") + source = inspect.getsource(publishing) + assert "write_text" not in source + assert "mkdir" not in source + + def test_publishing_never_switches(self) -> None: + """The switch primitives of the sibling paths stay unimported. + + An attribute check on the module — it fails when a switch helper is + imported at module level, but a function-local import would evade it. + The end-to-end guarantee lives in the integration snapshot. + """ + for forbidden in ( + "create_and_switch_branch", + "checkout_local_branch", + "create_branch_from_remote_tracking", + ): + assert not hasattr(publishing, forbidden) + + +# --- Logic tests: the fast creation-and-publication cycle --- + + +class TestPublishTopic: + @pytest.mark.parametrize( + ("todo", "template", "expected_content", "expected_message"), + [ + pytest.param( + "Fix retries.", + "goga: create topic {slug}", + "Fix retries.\n", + "goga: create topic feature-foo-bar", + id="basic", + ), + pytest.param( + "Fix retries.\n\nRetries ignore the cap.", + "goga: create topic {slug}", + "Fix retries.\n\nRetries ignore the cap.\n", + "goga: create topic feature-foo-bar", + id="multiline", + ), + pytest.param( + "Fix retries.", + "chore: fresh topic", + "Fix retries.\n", + "chore: fresh topic", + id="no-placeholder", + ), + ], + ) + def test_publish_topic_commits_todo_file( # noqa: PLR0913, PLR0917 — the parametrized scenario columns + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + todo: str, + template: str, + expected_content: str, + expected_message: str, + ) -> None: + """The cycle commits exactly one todo.md artifact — verbatim content. + + A multi-line todo reaches the commit verbatim plus one trailing + newline; a template without the ``{slug}`` placeholder is used as + is (plain ``str.replace`` — no format grammar). + """ + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.resolve_ref_commit.return_value = "abc123" + + result = publish_topic("Feature/Foo_Bar", todo, "origin/main", template, year="2026") + + assert cycle.commit_file_on_base.call_args.args[1] == ".goga/history/2026/feature-foo-bar/todo.md" + assert cycle.commit_file_on_base.call_args.args[2] == expected_content + assert cycle.commit_file_on_base.call_args.args[3] == expected_message + assert cycle.create_branch_at_commit.call_args.args[0] == "Feature/Foo_Bar" + cycle.resolve_current_branch_name.assert_called_once_with() + cycle.check_branch_occupancy.assert_called_once_with("Feature/Foo_Bar", "feature-foo-bar", "2026") + cycle.check_slug_occupancy.assert_called_once_with("feature-foo-bar", "2026") + cycle.origin_configured.assert_called_once_with() + cycle.resolve_ref_commit.assert_called_once_with("origin/main") + cycle.push_branch.assert_called_once_with("Feature/Foo_Bar") + cycle.delete_local_branch.assert_not_called() + # The parent recorder pins the cross-touchpoint order: every + # decision precedes the first mutation, and the mutations run + # build -> plant -> push. + assert cycle.recorder.mock_calls == [ + mock.call.resolve_current_branch_name(), + mock.call.check_branch_occupancy("Feature/Foo_Bar", "feature-foo-bar", "2026"), + mock.call.check_slug_occupancy("feature-foo-bar", "2026"), + mock.call.origin_configured(), + mock.call.resolve_ref_commit("origin/main"), + mock.call.commit_file_on_base( + "abc123", + ".goga/history/2026/feature-foo-bar/todo.md", + expected_content, + expected_message, + ), + mock.call.create_branch_at_commit("Feature/Foo_Bar", "<commit>"), + mock.call.push_branch("Feature/Foo_Bar"), + ] + assert result == ("Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar") + assert "\n" not in result + + def test_publish_topic_empty_todo_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An empty todo is one clean error — before every oracle and git call. + + The gate sits between the slug normalization and the current-branch + check, so not a single git mutation and not even the origin probe + of the decision chain runs — an empty todo means the caller never + meant to publish anything. + """ + monkeypatch.chdir(tmp_path) + _non_interactive(monkeypatch) + cycle = _wire_cycle(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + publish_topic("X", "", "origin/main", "tmpl") + + assert raised.value.message == ( + "the fast path needs a non-empty todo — pass the text or enter it interactively" + ) + cycle.resolve_current_branch_name.assert_not_called() + cycle.check_branch_occupancy.assert_not_called() + cycle.check_slug_occupancy.assert_not_called() + cycle.origin_configured.assert_not_called() + cycle.resolve_ref_commit.assert_not_called() + cycle.commit_file_on_base.assert_not_called() + _assert_no_mutation(cycle) + + def test_publish_topic_current_branch_hosting_slug_is_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The current branch hosting the slug: one clean error, no mutation.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.resolve_current_branch_name.return_value = "Feature/Foo_Bar" + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") + + assert raised.value.message == ( + "branch Feature/Foo_Bar already hosts topic 2026/feature-foo-bar — the fast path is only for fresh work" + ) + _assert_no_mutation(cycle) + + def test_publish_topic_conflict_without_terminal_fails_with_board_hint( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An occupancy conflict: the reason and the hint, no re-ask. + + The re-ask cycle is abolished — the conflict is one clean error on + every terminal kind; the non-interactive call here is the plainest + instance of the rule. + """ + monkeypatch.chdir(tmp_path) + _non_interactive(monkeypatch) + cycle = _wire_cycle(monkeypatch) + cycle.check_slug_occupancy.return_value = "topic 'feature-foo-bar' of 2026 is already hosted by branch 'alpha'" + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") + + assert raised.value.message == ( + "topic 'feature-foo-bar' of 2026 is already hosted by branch 'alpha'" + " — run 'goga topics board' to see the board" + ) + _assert_no_mutation(cycle) + + def test_publish_topic_branch_occupancy_conflict_skips_the_slug_oracle( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The oracle order: branch occupancy first — its conflict wins alone. + + The CODEMANIFEST pins the step: ``check_branch_occupancy`` runs + before ``check_slug_occupancy`` and the first conflict wins, so a + branch-occupancy conflict must leave the slug oracle unprobed — + probing both would double the git invocations and blur the reason. + """ + monkeypatch.chdir(tmp_path) + _non_interactive(monkeypatch) + cycle = _wire_cycle(monkeypatch) + cycle.check_branch_occupancy.return_value = "branch 'Feature/Foo_Bar' already exists" + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") + + assert raised.value.message == ( + "branch 'Feature/Foo_Bar' already exists — run 'goga topics board' to see the board" + ) + cycle.check_slug_occupancy.assert_not_called() + _assert_no_mutation(cycle) + + def test_publish_topic_failed_push_rolls_back_and_surfaces_reason( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A failed push deletes the planted branch and keeps its reason.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.push_branch.side_effect = subprocess.CalledProcessError( + 1, ["git", "push"], stderr="error: failed to push some refs" + ) + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") + + assert raised.value.message == "git failed: error: failed to push some refs" + cycle.delete_local_branch.assert_called_once_with("Feature/Foo_Bar") + + def test_publish_topic_rollback_failure_still_surfaces_push_reason( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A rollback failure of its own is suppressed — the push reason wins.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.push_branch.side_effect = subprocess.CalledProcessError( + 1, ["git", "push"], stderr="error: failed to push some refs" + ) + cycle.delete_local_branch.side_effect = subprocess.CalledProcessError( + 128, ["git", "update-ref"], stderr="fatal: unable to delete" + ) + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") + + assert raised.value.message == "git failed: error: failed to push some refs" + cycle.delete_local_branch.assert_called_once_with("Feature/Foo_Bar") + + def test_publish_topic_rollback_oserror_still_surfaces_push_reason( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An OS-level rollback failure is suppressed like a git one. + + The push guard catches every ``OSError``, so the rollback suppresses + every ``OSError`` too — a ``PermissionError`` of the deletion (an + ``OSError`` that is not a ``FileNotFoundError``) must not replace the + in-flight push reason with its own message. + """ + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.push_branch.side_effect = subprocess.CalledProcessError( + 1, ["git", "push"], stderr="error: failed to push some refs" + ) + cycle.delete_local_branch.side_effect = PermissionError("no more process handles") + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") + + assert raised.value.message == "git failed: error: failed to push some refs" + cycle.delete_local_branch.assert_called_once_with("Feature/Foo_Bar") + + def test_publish_topic_unresolvable_base_is_clean_error_before_mutations( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An unresolvable base fails before any mutation was made.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.resolve_ref_commit.side_effect = subprocess.CalledProcessError( + 128, ["git", "rev-parse"], stderr="fatal: Needed a single revision" + ) + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "no-such-ref", "m") + + assert raised.value.message == "git failed: fatal: Needed a single revision" + _assert_no_mutation(cycle) + + def test_publish_topic_without_origin_is_clean_error_before_mutations( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No origin remote: one clean error, nothing mutated.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.origin_configured.return_value = False + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") + + assert raised.value.message == "origin is not configured — the fast mode publishes to origin" + _assert_no_mutation(cycle) + + def test_publish_topic_detached_head_does_not_interfere( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A detached HEAD reads ``None`` and stays out of the way.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.resolve_current_branch_name.return_value = None + + result = publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") + + assert result == ("Created branch Feature/Foo_Bar and published topic 2026/feature-foo-bar") + cycle.resolve_current_branch_name.assert_called_once_with() + cycle.push_branch.assert_called_once_with("Feature/Foo_Bar") + + @pytest.mark.parametrize( + ("commit_message", "expected_message"), + [ + pytest.param(None, "goga: create topic feature-foo", id="default"), + pytest.param("feat({slug}): todo", "feat(feature-foo): todo", id="template"), + ], + ) + def test_publish_topic_default_message_when_none( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + commit_message: str | None, + expected_message: str, + ) -> None: + """A ``None`` template applies the built-in domain default. + + The default moved into the domain: the CLI and the configuration + pass ``None`` when neither provides a template, and the built-in + ``goga: create topic {slug}`` is substituted with the slug like any + other template; an explicit template keeps its own substitution. + """ + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.resolve_ref_commit.return_value = "c0" + + result = publish_topic("feature-foo", "Fix.", "origin/main", commit_message, year="2026") + + assert cycle.commit_file_on_base.call_args.args[3] == expected_message + assert result == "Created branch feature-foo and published topic 2026/feature-foo" + + def test_publish_topic_no_reask_on_conflict(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An occupancy conflict on a terminal is a clean error — no prompt. + + The re-ask cycle is abolished: ``click.prompt`` must never run, the + reason carries the board hint, and no commit is built. + """ + monkeypatch.chdir(tmp_path) + prompt = _terminal(monkeypatch) + cycle = _wire_cycle(monkeypatch) + cycle.check_branch_occupancy.return_value = "branch 'feature-foo' already exists" + + with pytest.raises(click.ClickException) as raised: + publish_topic("feature-foo", "T", "origin/main", "m", "2026") + + assert raised.value.message == ( + "branch 'feature-foo' already exists — run 'goga topics board' to see the board" + ) + prompt.assert_not_called() + _assert_no_mutation(cycle) + + @pytest.mark.parametrize( + "todo", + [ + pytest.param("Fix.\n", id="editor-form"), + pytest.param("Fix.", id="bare-value"), + ], + ) + def test_publish_topic_editor_todo_single_trailing_newline( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, todo: str + ) -> None: + """The committed todo carries exactly one trailing newline (fix D6). + + An editor-sourced todo already ends with ``\\n`` — click's + read-back — so the former unconditional ``f"{todo}\\n"`` published + a blank trailing line; the conditional rule keeps exactly one + newline for both the editor form and a bare value. + """ + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + + publish_topic("feature-foo", todo, "origin/main", year="2026") + + assert cycle.commit_file_on_base.call_args.args[2] == "Fix.\n" + + def test_publish_topic_empty_slug_is_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A name that normalizes to nothing is one clean error — no re-ask. + + The reason names the entered name; nothing is probed, prompted, or + mutated. + """ + monkeypatch.chdir(tmp_path) + prompt = _terminal(monkeypatch) + cycle = _wire_cycle(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + publish_topic("///", "T", "origin/main", "m") + + assert raised.value.message == ("branch name '///' normalizes to an empty topic slug") + prompt.assert_not_called() + cycle.resolve_current_branch_name.assert_not_called() + cycle.check_branch_occupancy.assert_not_called() + _assert_no_mutation(cycle) + + +# --- Infrastructure boundary --- + + +class TestPublishingInfrastructureBoundary: + def test_missing_git_binary_surfaces_as_clean_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A missing git binary during the cycle is a clean error.""" + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.resolve_ref_commit.side_effect = FileNotFoundError("git") + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") + + assert "git" in raised.value.message + _assert_no_mutation(cycle) + + def test_unwritable_repository_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An OS failure of the quarantined chain is a clean error. + + The temporary index of ``commit_file_on_base`` lives under ``.git`` — + an unwritable repository directory raises ``PermissionError`` (an + ``OSError``), which the boundary must fold into one clean error the + way ``create_topic`` folds its ``mkdir`` failures, not a traceback. + """ + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.commit_file_on_base.side_effect = PermissionError(13, "Permission denied") + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m") + + assert raised.value.message.startswith("cannot complete the publication:") + cycle.create_branch_at_commit.assert_not_called() + cycle.push_branch.assert_not_called() + + def test_publish_topic_oserror_push_rolls_back_too(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """An OS failure of the push rolls the planted branch back as well. + + ``push_branch`` can fail at spawn level (``PermissionError`` and kin + are ``OSError`` subclasses) — the full-rollback guarantee covers + every failed publication, not only git's own non-zero exits, or a + branch nobody asked for would survive the error. + """ + monkeypatch.chdir(tmp_path) + cycle = _wire_cycle(monkeypatch) + cycle.push_branch.side_effect = PermissionError(13, "Permission denied") + + with pytest.raises(click.ClickException) as raised: + publish_topic("Feature/Foo_Bar", "T", "origin/main", "m", "2026") + + assert raised.value.message.startswith("cannot complete the publication:") + cycle.delete_local_branch.assert_called_once_with("Feature/Foo_Bar") diff --git a/tests/topics/test_switching.py b/tests/topics/test_switching.py new file mode 100644 index 00000000..e3888e42 --- /dev/null +++ b/tests/topics/test_switching.py @@ -0,0 +1,922 @@ +"""Contract and logic tests for the entities declared in +``goga/topics/CODEMANIFEST`` with ``location: switching.py``: + +- ``SwitchCandidate(branch, topic, statuses, current, remote)`` — one + candidate of a switch-identifier resolution +- ``resolve_switch_candidates(identifier, year)`` — the read-only resolution +- ``switch_topic(identifier, year)`` — the switching orchestration + +The git boundary is mocked at the import point per the ``convention`` +practice — no git binary and no repository are touched. The ref-tree +helper shared with the board is mocked at its owner (``goga.topics.board``); +the working-copy scenarios use ``tmp_path`` + ``monkeypatch.chdir`` with the +real history path routines, and the scale is the ``builtin_scale`` fixture. +""" + +from __future__ import annotations + +import dataclasses +import inspect +import subprocess +import sys +import typing +from collections.abc import Callable +from pathlib import Path +from unittest import mock + +import click +import pytest +from goga.history import current_year +from goga.history.statuses import StatusScale +from goga.topics import ( + SwitchCandidate, + board, + resolve_switch_candidates, + switch_topic, + switching, +) +from goga.topics.git import BranchRef + +from tests.conftest import is_kw_only_dataclass + +# --- Shared scenario helpers --- + + +def _trees_reader(trees: dict[str, list[str]]) -> Callable[..., list[str]]: + """A ``read_ref_tree_paths`` stand-in answering by ref display name.""" + + def read(ref: str, prefix: str) -> list[str]: + assert prefix == ".goga/history/", "the resolution reads under the history root only" + return [path for path in trees.get(ref, []) if path.startswith(prefix)] + + return read + + +def _wire_resolution( + monkeypatch: pytest.MonkeyPatch, + scale: StatusScale, + inventory: list[BranchRef], + trees: dict[str, list[str]], + current: str | None, +) -> None: + """Patch the resolution's import points: scale, git inventory, trees, branch.""" + monkeypatch.setattr(switching, "assemble_status_scale", lambda: scale) + monkeypatch.setattr(switching, "list_branch_refs", lambda: inventory) + monkeypatch.setattr(switching, "resolve_current_branch_name", lambda: current) + monkeypatch.setattr(board, "read_ref_tree_paths", _trees_reader(trees)) + + +def _wire_mutations(monkeypatch: pytest.MonkeyPatch, clean: bool = True) -> tuple[mock.Mock, mock.Mock, mock.Mock]: + """Patch the switch mutations at their import points. + + Returns: + The cleanliness probe, the local checkout, and the remote-tracking + branch creation — all as recording mocks. + """ + cleanliness = mock.Mock(return_value=clean) + checkout = mock.Mock() + remote_creation = mock.Mock() + monkeypatch.setattr(switching, "is_working_tree_clean", cleanliness) + monkeypatch.setattr(switching, "checkout_local_branch", checkout) + monkeypatch.setattr(switching, "create_branch_from_remote_tracking", remote_creation) + return cleanliness, checkout, remote_creation + + +def _non_interactive(monkeypatch: pytest.MonkeyPatch) -> None: + """Make stdin a non-terminal — the re-ask path must abort cleanly.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + +def _interactive(monkeypatch: pytest.MonkeyPatch) -> None: + """Make stdin a terminal — the todo entry of the switch is reachable.""" + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + + +def _wire_entry(monkeypatch: pytest.MonkeyPatch) -> mock.Mock: + """Patch the todo entry at its import point in ``switching``. + + Returns: + The entry — a recording mock. + """ + entry = mock.Mock(return_value=True) + monkeypatch.setattr(switching, "enter_topic_todo", entry) + return entry + + +def _working_copy_topic(cwd: Path, year: str, slug: str, artifacts: list[str]) -> None: + """Create the working-copy topic directory with its artifact files.""" + for artifact in artifacts: + path = cwd / ".goga" / "history" / year / slug / artifact + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("artifact", encoding="utf-8") + + +def _twin_inventory() -> list[BranchRef]: + """The design-scenario inventory: a local branch and its remote twin.""" + return [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/feat/a", remote=True), + ] + + +def _twin_trees() -> dict[str, list[str]]: + """The design-scenario ref trees: one planned topic on both refs.""" + return { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "origin/feat/a": [".goga/history/2026/feat-a/plan.md"], + } + + +# --- Contract tests --- + + +class TestSwitchingContract: + def test_entities_are_importable_from_the_cell_facade(self) -> None: + """``SwitchCandidate`` and the two switch routines live on the cell facade.""" + import goga.topics as cell + + assert cell.SwitchCandidate is SwitchCandidate + assert cell.resolve_switch_candidates is resolve_switch_candidates + assert cell.switch_topic is switch_topic + for name in ("SwitchCandidate", "resolve_switch_candidates", "switch_topic"): + assert name in cell.__all__ + + def test_switch_candidate_is_a_frozen_kw_only_dataclass(self) -> None: + """``@dataclass(frozen=True, kw_only=True)`` with the five declared fields.""" + assert dataclasses.is_dataclass(SwitchCandidate) + assert SwitchCandidate.__dataclass_params__.frozen is True + assert is_kw_only_dataclass(SwitchCandidate) + assert typing.get_type_hints(SwitchCandidate) == { + "branch": str, + "topic": str | None, + "statuses": list[str], + "current": bool, + "remote": bool, + } + candidate = SwitchCandidate(branch="feat/a", topic="feat-a", statuses=["planned"], current=True, remote=False) + assert candidate.branch == "feat/a" + assert candidate.topic == "feat-a" + assert candidate.statuses == ["planned"] + assert candidate.current is True + assert candidate.remote is False + with pytest.raises(dataclasses.FrozenInstanceError): + candidate.branch = "other" # type: ignore[misc] + with pytest.raises(TypeError): + SwitchCandidate("feat/a", "feat-a", ["planned"], True, False) # type: ignore[misc] + + def test_resolve_switch_candidates_signature(self) -> None: + """``resolve_switch_candidates(identifier, year=None) -> list[...]``.""" + signature = inspect.signature(resolve_switch_candidates) + assert list(signature.parameters) == ["identifier", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(resolve_switch_candidates) + assert hints == { + "identifier": str, + "year": str | None, + "return": list[SwitchCandidate], + } + + def test_switch_topic_signature(self) -> None: + """``switch_topic(identifier, todo=False, year=None) -> str``.""" + signature = inspect.signature(switch_topic) + assert list(signature.parameters) == ["identifier", "todo", "year"] + assert all( + parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD for parameter in signature.parameters.values() + ) + assert signature.parameters["todo"].default is False + assert signature.parameters["year"].default is None + hints = typing.get_type_hints(switch_topic) + assert hints == {"identifier": str, "todo": bool, "year": str | None, "return": str} + + +# --- Logic tests: resolution --- + + +class TestResolveSwitchCandidates: + def test_resolve_switch_candidates_exact_before_prefix( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An exact tier always excludes the prefix tier of the same input.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/ab", remote=False), + BranchRef(name="main", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/ab": [".goga/history/2026/feat-ab/prd.md"], + "main": ["README.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) + + exact_branch = resolve_switch_candidates("feat/a", "2026") + assert [(c.branch, c.topic) for c in exact_branch] == [("feat/a", "feat-a")] + + exact_slug = resolve_switch_candidates("feat-ab", "2026") + assert [(c.branch, c.topic) for c in exact_slug] == [("feat/ab", "feat-ab")] + + prefix = resolve_switch_candidates("feat", "2026") + assert [(c.branch, c.topic) for c in prefix] == [ + ("feat/a", "feat-a"), + ("feat/ab", "feat-ab"), + ] + + def test_resolve_switch_candidates_branch_without_topic( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A branch without a topic is a valid candidate — ``q4:A``.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) + + candidates = resolve_switch_candidates("main", "2026") + + assert len(candidates) == 1 + assert candidates[0].branch == "main" + assert candidates[0].topic is None + assert candidates[0].statuses == [] + + def test_resolve_switch_candidates_empty_slug_identifier( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A non-ASCII identifier resolves by exact branch name only.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="🚀", remote=False), + BranchRef(name="feat/a", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "🚀") + + candidates = resolve_switch_candidates("🚀", "2026") + + assert [(c.branch, c.topic, c.statuses) for c in candidates] == [("🚀", None, [])] + + def test_resolve_switch_candidates_orders_local_before_remote( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Within a tier: locals first, then by branch, then by topic.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="origin/zz", remote=True), + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/aa", remote=True), + ] + trees = { + "feat/a": [".goga/history/2026/feat-x/plan.md"], + "origin/aa": [".goga/history/2026/feat-x/prd.md"], + "origin/zz": [".goga/history/2026/feat-x/adr.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) + + candidates = resolve_switch_candidates("feat-x", "2026") + + assert [(c.branch, c.remote, c.statuses) for c in candidates] == [ + ("feat/a", False, ["planned"]), + ("origin/aa", True, ["defined"]), + ("origin/zz", True, ["discovered"]), + ] + + def test_resolve_switch_candidates_local_beats_remote_twin( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A branch and its remote twin hosting one slug resolve to the local + branch — an unambiguous slug never reaches a prompt.""" + monkeypatch.chdir(tmp_path) + _wire_resolution(monkeypatch, builtin_scale, _twin_inventory(), _twin_trees(), None) + + candidates = resolve_switch_candidates("feat-a", "2026") + + assert [(c.branch, c.remote) for c in candidates] == [("feat/a", False)] + + def test_resolve_switch_candidates_branch_appears_once( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A branch hosting several topics of the year is one candidate — the + branch never repeats in the tier list.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="other", remote=False), + ] + trees = { + "main": [ + ".goga/history/2026/feat-b/plan.md", + ".goga/history/2026/feat-a/prd.md", + ], + "other": ["README.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "other") + + candidates = resolve_switch_candidates("main", "2026") + + # The first entry of the tier order (branch, then topic) carries the + # branch — the alphabetically first hosted topic. + assert [(c.branch, c.topic, c.statuses) for c in candidates] == [("main", "feat-a", ["defined"])] + + def test_resolve_switch_candidates_working_copy_of_current_branch( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The current branch's statuses come from the working copy.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/other", remote=True), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/notes.txt"], + "origin/other": [".goga/history/2026/feat-a/notes.txt"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "feat/a") + + candidates = resolve_switch_candidates("feat-a", "2026") + + # The uncommitted plan.md is visible on the current branch; the same + # topic on another ref reads the ref tree only. + assert [(c.branch, c.statuses, c.current) for c in candidates] == [ + ("feat/a", ["planned"], True), + ("origin/other", ["empty"], False), + ] + + +# --- Logic tests: switching --- + + +class TestSwitchTopic: + def test_switch_topic_single_candidate_switches( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """One local candidate, clean tree: checkout and the result line.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=True) + + result = switch_topic("feat/a", year="2026") + + assert result == "Switched to branch feat/a" + checkout.assert_called_once_with("feat/a") + + def test_switch_topic_slug_with_pushed_twin_switches_local( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A slug hosted by a branch and its remote twin checks out the local + branch — the twin never turns an unambiguous switch into a prompt.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="feat/a", remote=False), + BranchRef(name="origin/feat/a", remote=True), + ] + trees = {**_twin_trees(), "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + + result = switch_topic("feat-a", year="2026") + + assert result == "Switched to branch feat/a" + checkout.assert_called_once_with("feat/a") + creation.assert_not_called() + + def test_switch_topic_branch_hosting_several_topics_switches( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An exact branch name hosting several topics of the year is one + candidate — the switch proceeds without a selection prompt.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="main", remote=False), + BranchRef(name="other", remote=False), + ] + trees = { + "main": [ + ".goga/history/2026/feat-b/plan.md", + ".goga/history/2026/feat-a/prd.md", + ], + "other": ["README.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "other") + _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=True) + + result = switch_topic("main", year="2026") + + assert result == "Switched to branch main" + checkout.assert_called_once_with("main") + + def test_switch_topic_idempotent_when_already_on_host( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Already on the host: idempotent success, no probe, no mutation.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, "2026", "feat-a", ["plan.md"]) + _wire_resolution(monkeypatch, builtin_scale, _twin_inventory(), _twin_trees(), "feat/a") + cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + + result = switch_topic("feat/a") + + assert result == "Already on branch feat/a" + cleanliness.assert_not_called() + checkout.assert_not_called() + creation.assert_not_called() + + def test_switch_topic_dirty_tree_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A dirty working tree is a clean error — and no git switch.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=False) + + with pytest.raises(click.ClickException) as raised: + switch_topic("feat/a", year="2026") + + assert raised.value.message == "working tree is dirty — commit or stash before switching" + checkout.assert_not_called() + + def test_switch_topic_remote_only_creates_branch( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A remote-only host: the local branch is created from the ref.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="origin/feat/b", remote=True)] + trees = {"origin/feat/b": [".goga/history/2026/feat-b/prd.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) + _cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + + result = switch_topic("feat-b", year="2026") + + assert result == "Created branch feat/b from origin/feat/b" + checkout.assert_not_called() + creation.assert_called_once_with(BranchRef(name="origin/feat/b", remote=True)) + + def test_switch_topic_multiple_candidates_prompt( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Several candidates: the numbered list, then the number prompt.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/ab", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/ab": [".goga/history/2026/feat-ab/prd.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) + _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=True) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + prompt = mock.Mock(return_value=2) + monkeypatch.setattr(click, "prompt", prompt) + + result = switch_topic("feat", year="2026") + + assert result == "Switched to branch feat/ab" + checkout.assert_called_once_with("feat/ab") + assert prompt.call_args.args[0] == "Select a branch by number" + prompt_type = prompt.call_args.kwargs["type"] + assert isinstance(prompt_type, click.IntRange) + assert (prompt_type.min, prompt_type.max) == (1, 2) + captured = capsys.readouterr() + assert "1) feat/a (feat-a) [planned]" in captured.out + assert "2) feat/ab (feat-ab) [defined]" in captured.out + + @pytest.mark.parametrize("bad_answer", ["0", "9"]) + def test_switch_topic_prompt_rejects_out_of_range_input( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + bad_answer: str, + ) -> None: + """The prompt is bounded by ``IntRange(1, N)`` — a bad answer re-asks. + + Both invalid sides of the range are exercised — ``0`` below the + minimum and ``9`` above the maximum — and the re-ask then accepts + the minimum boundary ``1``. + """ + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/ab", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/ab": [".goga/history/2026/feat-ab/prd.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) + _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=True) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + answers = iter([bad_answer, "1"]) + monkeypatch.setattr(click.termui, "visible_prompt_func", lambda _text: next(answers)) + + result = switch_topic("feat", year="2026") + + assert result == "Switched to branch feat/a" + checkout.assert_called_once_with("feat/a") + assert next(answers, None) is None, "both answers were consumed by the re-asking prompt" + captured = capsys.readouterr() + # The out-of-range complaint came through click's own error echo. + assert "not in the range" in captured.out + captured.err + + def test_switch_topic_no_candidates_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An identifier nothing hosts is a clean error with a board hint.""" + monkeypatch.chdir(tmp_path) + inventory = [BranchRef(name="main", remote=False)] + _wire_resolution(monkeypatch, builtin_scale, inventory, {"main": ["README.md"]}, None) + _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=True) + + with pytest.raises(click.ClickException) as raised: + switch_topic("nope") + + assert raised.value.message == ("no branch hosts 'nope' — run 'goga topics board' to see the board") + checkout.assert_not_called() + + def test_switch_topic_non_interactive_multiple_candidates_fails_with_list( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Several candidates without a terminal: the list is the clean error.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/ab", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/ab": [".goga/history/2026/feat-ab/prd.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) + cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": False})) + + with pytest.raises(click.ClickException) as raised: + switch_topic("feat", year="2026") + + assert "feat/a" in raised.value.message + assert "feat/ab" in raised.value.message + assert "1)" in raised.value.message + assert "2)" in raised.value.message + cleanliness.assert_not_called() + checkout.assert_not_called() + creation.assert_not_called() + + +# --- Logic tests: switching with the todo entry --- + + +class TestSwitchTopicTodo: + def test_switch_topic_todo_enters_after_switch( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """``todo=True``: the checkout runs first, then the entry of the switched topic.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feature-foo", remote=False), + BranchRef(name="main", remote=False), + ] + trees = { + "feature-foo": [".goga/history/2026/feature-foo/plan.md"], + "main": ["README.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + _cleanliness, checkout, _creation = _wire_mutations(monkeypatch, clean=True) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + order = mock.Mock() + order.attach_mock(checkout, "checkout") + order.attach_mock(entry, "entry") + + result = switch_topic("feature-foo", todo=True, year="2026") + + assert result == "Switched to branch feature-foo" + entry.assert_called_once_with("feature-foo", "2026") + assert order.mock_calls == [ + mock.call.checkout("feature-foo"), + mock.call.entry("feature-foo", "2026"), + ] + + def test_switch_topic_todo_idempotent_still_enters( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Already on the host under ``todo=True``: the idempotent line — the + entry still runs, without a cleanliness probe or a checkout.""" + monkeypatch.chdir(tmp_path) + _working_copy_topic(tmp_path, current_year(), "feature-foo", ["plan.md"]) + inventory = [BranchRef(name="feature-foo", remote=False)] + trees = {"feature-foo": [".goga/history/2026/feature-foo/plan.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "feature-foo") + cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + + result = switch_topic("feature-foo", todo=True) + + assert result == "Already on branch feature-foo" + entry.assert_called_once_with("feature-foo", None) + cleanliness.assert_not_called() + checkout.assert_not_called() + creation.assert_not_called() + + def test_switch_topic_todo_candidate_without_topic_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """``todo=True`` on a candidate without a topic: a clean error after + the choice — switching creates nothing.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feature-foo", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feature-foo": ["README.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + entry = _wire_entry(monkeypatch) + _interactive(monkeypatch) + + with pytest.raises(click.ClickException, match="topic"): + switch_topic("feature-foo", todo=True, year="2026") + + cleanliness.assert_not_called() + checkout.assert_not_called() + creation.assert_not_called() + entry.assert_not_called() + + def test_switch_topic_todo_non_tty_error_before_resolution( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``todo=True`` without a terminal: the clean error fires before any + resolution — ordering, not just the error.""" + monkeypatch.chdir(tmp_path) + _non_interactive(monkeypatch) + resolver = mock.Mock(side_effect=AssertionError("the resolution must not run")) + monkeypatch.setattr(switching, "resolve_switch_candidates", resolver) + + with pytest.raises(click.ClickException, match="interactive"): + switch_topic("anything", todo=True) + + resolver.assert_not_called() + + def test_switch_topic_several_candidates_non_tty_with_todo( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Several candidates, no terminal, ``todo=True``: the todo check + fires first — the error names the terminal, not the candidate list.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/ab", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/ab": [".goga/history/2026/feat-ab/prd.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, None) + cleanliness, checkout, creation = _wire_mutations(monkeypatch, clean=True) + _non_interactive(monkeypatch) + + with pytest.raises(click.ClickException) as raised: + switch_topic("feat", todo=True, year="2026") + + assert "interactive" in raised.value.message + assert "1)" not in raised.value.message + assert "feat/a" not in raised.value.message + cleanliness.assert_not_called() + checkout.assert_not_called() + creation.assert_not_called() + + +# --- Infrastructure boundary --- + + +class TestSwitchingInfrastructureBoundary: + def test_git_failure_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A git infrastructure failure with stderr becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + failure = subprocess.CalledProcessError( + returncode=128, cmd=["git", "for-each-ref"], stderr="fatal: not a git repository" + ) + monkeypatch.setattr(switching, "assemble_status_scale", lambda: builtin_scale) + monkeypatch.setattr(switching, "list_branch_refs", mock.Mock(side_effect=failure)) + + with pytest.raises(click.ClickException) as raised: + resolve_switch_candidates("feat/a", "2026") + + assert "fatal: not a git repository" in raised.value.message + + def test_missing_git_binary_at_resolution_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A missing git binary during the resolution is a clean error.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(switching, "assemble_status_scale", lambda: builtin_scale) + monkeypatch.setattr(switching, "list_branch_refs", mock.Mock(side_effect=FileNotFoundError("git"))) + + with pytest.raises(click.ClickException) as raised: + resolve_switch_candidates("feat/a", "2026") + + assert "git is not available" in raised.value.message + + def test_broken_tool_package_import_surfaces_as_clean_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The fatal scale-assembly ``ImportError`` keeps its package name.""" + monkeypatch.chdir(tmp_path) + broken = ImportError("package goga_tool_bad failed to import: boom") + monkeypatch.setattr(switching, "assemble_status_scale", mock.Mock(side_effect=broken)) + + with pytest.raises(click.ClickException) as raised: + switch_topic("feat/a", year="2026") + + assert raised.value.message == "package goga_tool_bad failed to import: boom" + + def test_switch_mutation_failure_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A failing checkout mutation becomes a ``ClickException``.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + monkeypatch.setattr(switching, "is_working_tree_clean", mock.Mock(return_value=True)) + failure = subprocess.CalledProcessError( + returncode=1, cmd=["git", "switch", "feat/a"], stderr="error: cannot switch" + ) + monkeypatch.setattr(switching, "checkout_local_branch", mock.Mock(side_effect=failure)) + + with pytest.raises(click.ClickException) as raised: + switch_topic("feat/a", year="2026") + + assert "error: cannot switch" in raised.value.message + + def test_missing_git_binary_at_mutation_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A missing git binary during the mutation phase is a clean error.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + monkeypatch.setattr(switching, "is_working_tree_clean", mock.Mock(side_effect=FileNotFoundError("git"))) + + with pytest.raises(click.ClickException) as raised: + switch_topic("feat/a", year="2026") + + assert "git" in raised.value.message + + def test_broken_import_after_resolution_surfaces_as_clean_error( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A fatal ``ImportError`` after the resolution is a clean error. + + The scale of a command run assembles inside the resolution, so the + orchestration wrapper's own ``ImportError`` boundary is probed here: + the cleanliness check raising it after the candidates resolved. + """ + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="main", remote=False), + ] + trees = {"feat/a": [".goga/history/2026/feat-a/plan.md"], "main": ["README.md"]} + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + broken = ImportError("package goga_tool_bad failed to import: boom") + monkeypatch.setattr(switching, "is_working_tree_clean", mock.Mock(side_effect=broken)) + + with pytest.raises(click.ClickException) as raised: + switch_topic("feat/a", year="2026") + + assert raised.value.message == "package goga_tool_bad failed to import: boom" + + def test_selection_prompt_abort_leaves_repository_untouched( + self, + builtin_scale: StatusScale, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Ctrl-C at the selection prompt propagates as ``click.Abort`` — no mutation.""" + monkeypatch.chdir(tmp_path) + inventory = [ + BranchRef(name="feat/a", remote=False), + BranchRef(name="feat/b", remote=False), + BranchRef(name="main", remote=False), + ] + trees = { + "feat/a": [".goga/history/2026/feat-a/plan.md"], + "feat/b": [".goga/history/2026/feat-b/plan.md"], + "main": ["README.md"], + } + _wire_resolution(monkeypatch, builtin_scale, inventory, trees, "main") + monkeypatch.setattr(sys, "stdin", mock.Mock(**{"isatty.return_value": True})) + monkeypatch.setattr(click, "prompt", mock.Mock(side_effect=click.Abort())) + cleanliness, checkout, creation = _wire_mutations(monkeypatch) + + with pytest.raises(click.Abort): + switch_topic("feat", year="2026") + + cleanliness.assert_not_called() + checkout.assert_not_called() + creation.assert_not_called()