diff --git a/.gitignore b/.gitignore index 3fcb360..69f81bc 100644 --- a/.gitignore +++ b/.gitignore @@ -335,3 +335,5 @@ ASALocalRun/ # Cake build output (build/) .artifacts/ .tests/ + +/TestResults diff --git a/CLAUDE.md b/CLAUDE.md index 118f040..0005188 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,17 +10,17 @@ Repository language is always English. The kit is designed to be used by English It has two deliverables: -1. **Plugin** (`skills/`): skills, agents, hooks, and commands in the Claude Code plugin layout. +1. **Plugin** (`skills/`): skills and research agent definitions in the Claude Code plugin layout. Hooks and commands come later. 2. **Engine** (`src/`): a .NET 10 CLI, shipped two ways: - as a `dotnet` global tool - as self-contained single-file binaries (no .NET install needed) for `osx-arm64`, `linux-x64`, `linux-arm64`, `win-x64`, `win-arm64` ### v1 scope -- **Gated workflow phases:** spec → plan → implement → verify → review, with an explicit approval gate between phases. -- **Quality gates:** build, test, lint, and coverage checks must pass before a phase can complete. -- **State & traceability:** workflow state is saved, requirements are mapped to the tests that cover them, and reports are generated. -- **Install/sync:** the engine installs and updates the kit's skills in a target repository, for each supported agent. +- **Gated workflow profiles:** `feature` (new → refine → map → plan → implement → verify → finish), `bugfix` (new → diagnose → plan → implement → verify → finish), and `quick` (new → quick → finish). The agent checks each gate when the next phase starts; the human approves the transition. Each gate's approval and checklist can be switched off in `gates.json`. +- **Quality gates:** the Definition of Ready (gate into `plan`) and Definition of Done (gate into `finish`) are checklists. In v1 the agent runs build, test, lint, and coverage with the commands approved in `plan.md` and records the results; the CLI enforcing and running them comes later. +- **State & traceability:** workflow state and approval history are saved in `.shadekit/`, requirements are mapped to the tests that prove them, and `finish` can write a report (`report` in `config.json`). +- **Install/sync:** the engine installs and updates the kit's skills at user level for each supported agent (`integrations install/update`) and initialises and upgrades a repository's `.shadekit/` (`init`, `upgrade`). Nothing is installed into a repository. ## Target agents @@ -33,7 +33,7 @@ If a design choice can stay agent-agnostic **without losing functionality or pre | Path | Purpose | |---|---| | `skills/` | Shipped plugin content | -| `src/` | Production .NET projects: `CreativeCoders.ShadeKit.Cli` (the `shade-kit` CLI) and `CreativeCoders.ShadeKit.Core` (engine logic) | +| `src/` | Production .NET projects: `CreativeCoders.ShadeKit.Cli` (the `shade-kit` CLI) and `CreativeCoders.ShadeKit.Core` (engine logic). The templates that `init` writes live in `src/CreativeCoders.ShadeKit.Core/Templates/Resources/` (`.template.md`, checklists in `checks/`) and are embedded into the Core assembly; their repository paths are listed in `ShippedTemplates`. | | `tests/` | One test project per production project (`.Tests`) | | `build/` | Cake Frosting build project (`CreativeCoders.CakeBuild`), run through `build.sh` / `build.ps1` / `build.cmd`. Not part of `ShadeKit.slnx`; it has its own `Directory.Build.props` and `Directory.Packages.props`. | | `.github/` | GitHub Actions workflows and Dependabot configuration | @@ -45,7 +45,7 @@ If a design choice can stay agent-agnostic **without losing functionality or pre - Target `net10.0`. The SDK is pinned in `global.json` (`sdk.version`, `rollForward: patch`); CI installs exactly that version with `actions/setup-dotnet`. - Use one `.slnx` solution at the repository root (`ShadeKit.slnx`). - Use Central Package Management: all NuGet versions go in `Directory.Packages.props`. Never put `Version=` on a `PackageReference`. -- Put shared build settings (nullable, implicit usings, analyzers) in `Directory.Build.props`, not in individual `.csproj` files. Analyzers run with `AnalysisLevel=latest-recommended`, `EnforceCodeStyleInBuild`, and `TreatWarningsAsErrors`, so every warning breaks the build. `tests/.editorconfig` turns off CA1707 because test method names use underscores (`Method_Scenario_ExpectedResult`). +- Put shared build settings (nullable, implicit usings, analyzers) in `Directory.Build.props`, not in individual `.csproj` files. Analyzers run with `AnalysisLevel=latest-recommended`, `EnforceCodeStyleInBuild`, and `TreatWarningsAsErrors`, so every warning breaks the build. SonarAnalyzer.CSharp runs in every solution project as a `GlobalPackageReference` in `Directory.Packages.props`; a Sonar finding that is not fixed is suppressed with `[SuppressMessage(..., Justification = "...")]` on the class or method, not in `.editorconfig`. `tests/.editorconfig` turns off CA1707 because test method names use underscores (`Method_Scenario_ExpectedResult`). - Files are UTF-8 without BOM with LF line endings (`.editorconfig`). - Tests use xUnit v3, FakeItEasy, and AwesomeAssertions. They run on Microsoft.Testing.Platform (enabled in `global.json`) with `Microsoft.Testing.Extensions.TrxReport` and `Microsoft.Testing.Extensions.CodeCoverage` (`dotnet test --coverage`), which is what the Cake test task drives. - Versioning: GitVersion (`GitVersion.yml`, `GitVersion.MsBuild` as `GlobalPackageReference`) derives one version from the git history for every project, the tool package, and the binaries. Never put a version in a `.csproj` or `.props` file. Builds need the full history (`fetch-depth: 0`). A release is a tag `v` on `main`. @@ -59,7 +59,8 @@ If a design choice can stay agent-agnostic **without losing functionality or pre dotnet build # from repo root (.slnx) dotnet test dotnet test --coverage # with coverage (writes TestResults/) -dotnet run --project src/CreativeCoders.ShadeKit.Cli -- features list +dotnet run --project src/CreativeCoders.ShadeKit.Cli -- init # initialises the git repository of the current directory +dotnet run --project src/CreativeCoders.ShadeKit.Cli -- feature list dotnet pack src/CreativeCoders.ShadeKit.Cli # dotnet global tool package (command: shade-kit) dotnet publish src/CreativeCoders.ShadeKit.Cli -c Release -r # self-contained single-file binary ``` diff --git a/Directory.Packages.props b/Directory.Packages.props index ee0a853..cac857a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,8 @@ package and the binaries carry the same version. Keep it in sync with GitVersion.Tool in build/Program.cs. --> + + diff --git a/README.md b/README.md index 0dd95fd..ca42eaa 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,22 @@ # Smart Harness for AI Driven Engineering Kit -This project is a harnessed workflow guided Toolkit helping you to build and test your software with AI agents. -It is designed to be a comprehensive solution for managing the entire software development lifecycle, from code generation to testing and deployment, all while leveraging the power of AI. +SHADE kit runs AI coding agents through a gated feature workflow: from a raw requirement to a clarified spec, a plan, the implementation, and a verified result with a report. It is built for small teams of 2–5 developers who work on features in parallel, each on its own branch, with Claude Code, GitHub Copilot, or OpenAI Codex CLI. + +> **Status:** in development. Most commands described here are planned; today the CLI provides `shade-kit version`, `shade-kit init`, and `shade-kit feature list`. + +## How it works + +SHADE kit has two parts. The skills are the prompts the agent follows; the `shade-kit` CLI is the memory and the referee. + +- **Skills** (`skills/`): one skill per phase plus shared skills for interviews, gate checks, research, and reviews. They are installed once per machine for each agent (`shade-kit integrations install`), never into a repository. +- **Engine** (`shade-kit`): a .NET CLI that keeps the feature state in `.shadekit/` inside the repository, committed and merged with the code. Agents call it with `--json`. +- **Phases:** `new` → `refine` → `map` → `plan` → `implement` → `verify` → `finish` in the `feature` profile. The `bugfix` profile replaces `refine` and `map` with `diagnose`; the `quick` profile runs `new` → `quick` → `finish` for small changes. +- **Gates:** the human approves every phase transition. The Definition of Ready guards `plan`, the Definition of Done guards `finish`; each gate can be switched off per repository. +- **Traceability:** requirements get stable IDs and are mapped to the tests that prove them; `finish` writes a report with requirements, tests, check results, and approvals. + +SHADE kit never commits, pushes, or opens pull requests; that stays with the developer. + +Design notes: [concept](research/shade-kit-concept.md), [skills](research/shade-kit-skills.md), and the [v1 feature packages](docs/feature-packages/README.md). ## Build diff --git a/docs/adr/0001-shadekit-version-drift.md b/docs/adr/0001-shadekit-version-drift.md deleted file mode 100644 index ac70132..0000000 --- a/docs/adr/0001-shadekit-version-drift.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -status: proposed -date: 2026-10-13 ---- - -# Handling version drift between `.shadekit/` and the installed engine - -A repository initialised by SHADE kit keeps its kit data in `.shadekit/`, fully checked into git. The installed `shade-kit` engine can be newer than that data (the repository needs an upgrade) or older (someone else already upgraded the repository). We decided that every JSON file in `.shadekit/` carries its own format version and has its own migration chain, that templates are tracked through a hash lock file, that an engine refuses to work with a format it does not know, and that data is only ever rewritten by an explicit `shade-kit upgrade`. Per-file versions are the only option we found that stays correct after git merges of branches with different format versions. - -## Context - -State of the code when this was decided: - -- `.shadekit/` is the kit root. It is meant to hold `config.json`, `features/`, and `templates/` (`src/CreativeCoders.ShadeKit.Core/Store/KitStorePaths.cs`). -- The engine reads only `features/` so far (`KitStore.GetFeatureNames`). Nothing stores a version yet, and there is no migration logic. -- The kit repository ships `templates/spec.template.md`, `plan.template.md`, and `tasks.template.md`, which are still empty. How they are shipped (embedded resource or plain files) is not decided yet. - -Requirements gathered for this decision: - -| Topic | Requirement | -|---|---| -| Templates | Users may change them freely. They are plain text: no engine or skill parses their structure. A new kit version of a template is offered as a diff, and the user decides whether to take it. | -| `config.json` | Users own the values. The engine owns the schema and migrates it. | -| `features/` (workflow state) | Owned by the engine. `upgrade` migrates every feature in place, including features that are in the middle of a phase. | -| Skills and agent files | Not stored in the target repository. The plugin and the CLI are released together with the same version number, and a skill checks `shade-kit version` before it runs. | -| Measure of "newer" | An integer format version per artifact type. It changes only when the structure changes, not with every engine release. | -| Repository newer than engine | Hard stop with an exit code from `ShadeKitCliExitCodes` and the message "shade-kit >= X required". | -| Engine newer than repository | Commands report that `shade-kit upgrade` is needed. Only `upgrade` writes migrated data. | -| Unknown JSON fields | Preserved on read and write (round-trip, for example via `[JsonExtensionData]`). Adding a field therefore does not need a new format version. | -| Drift directions | Both directions matter. Several engine versions running in one repository at the same time is not a target scenario. | - -## Decision - -1. **Format version in every JSON file.** `config.json` and every feature state file carry a `schemaVersion` field. Each artifact type has its own chain of small migration steps (v1→v2→v3) that work on `JsonNode`. -2. **Version check on every command.** If a file has a `schemaVersion` higher than the engine supports, the command stops with an exit code and names the minimum engine version. If a file is older, the command reports that `shade-kit upgrade` is needed and writes nothing. -3. **Upgrade only through `shade-kit upgrade`.** It refuses to run while `.shadekit/` has uncommitted changes, so git is the backup. It writes all files or none: everything is written to temporary files first and then renamed into place. -4. **Unknown fields are preserved.** An older engine reading a file in a format version it knows writes back any fields it does not know, unchanged. Changes that only add fields do not raise the format version. -5. **Template lock file.** `.shadekit/templates.lock.json` records, for each shipped template, the kit version and the content hash at install time. On `upgrade`, templates the user has not changed are replaced without asking. A changed template is left as it is, and the new kit version is written next to it as `.new` with a diff shown to the user. -6. **Plugin and CLI release together.** They share one version number and check each other's version at startup. - -## Considered options - -Every option shares the requirements above. The options differ in what lives in the repository and where versions are recorded. - -| Option | Templates in the repository | `config.json` | Versions and hashes | -|---|---|---|---| -| V1 Copy + manifest | All templates are copied on `init` | Complete file | One central `.shadekit/manifest.json` with a format version per artifact type and the template hashes | -| V2 Overlay (eject) | Only templates the user has taken over with `shade-kit template eject `. The engine serves the rest | Sparse: only values that differ from the defaults | Manifest only for templates that were taken over | -| **V3 Self-describing (chosen)** | As V1 | As V1 | `schemaVersion` in every JSON file, plus a small template lock file | -| V4 V1 + version pinning | As V1 | As V1 | As V1. In addition the repository pins the engine version, through `.config/dotnet-tools.json` or a `requiredVersion` field | - -### The case that decides it: git merges - -1. Branch A runs `upgrade`. The central manifest now says state format 3, and every feature state file has been migrated. -2. Branch B still uses the old engine and creates a new feature with state format 2. -3. After the merge, the manifest says 3, but one state file is in format 2. - -With a single central version (V1, V2, V4), the engine reads that file as format 3, which leads to an error or a damaged state. The hard stop does not help, because the recorded version is wrong. With a version in every file (V3), the engine sees format 2 for that one file and reports that `upgrade` is needed. - -### Scoring - -Scale 1–5, 5 is best. For effort, 5 means least effort. Weights: robustness, effort, and user experience ×3, agent neutrality ×1. - -| Criterion | V1 | V2 | V3 | V4 | -|---|---|---|---|---| -| Robustness / data safety | 3: manifest and files can disagree after merges | 4: fewer files to migrate, but the same merge problem; templates not taken over can differ between team members with different engine versions | **5**: every file describes itself, safe across merges | 4: pinning stops the team from running different versions, but the merge problem remains | -| Implementation effort | 4: manifest, hashes, `.new` files, migration chains | 2: resolving each template from the engine or the repository, an `eject` command, and a sparse config that is awkward to serialise with System.Text.Json | **4**: as V1; checks and migrations per file are simpler | 3: nearly free through `.config/dotnet-tools.json`, but self-contained binaries need their own mechanism | -| User experience | 3: everything is visible, but upgrades produce large diffs and `.new` files | 4: smallest repository and few template conflicts, but templates are harder to find | 3: as V1 | 4: hardly any hard stops, but everyone must work with a locally installed tool (`dotnet tool restore`) | -| Agent neutrality | 5 | 3: Copilot and Codex expect templates as files in the repository | 5 | 5 | -| **Weighted total** | 35 | 33 | **41** | 38 | - -## Consequences - -- Every JSON model in the engine needs a `schemaVersion` property, a store for unknown fields, and a registered migration chain, even when it is still at version 1. -- Raising a format version is a deliberate change. It always ships a migration step and a test that migrates a fixture from the previous version. -- Within one format version, changes may only add fields, and the engine must accept a file where the new fields are missing. Renaming, removing, or changing the meaning of a field raises the format version. -- After a merge, a repository can contain files with different format versions. This is a valid state, and `upgrade` resolves it. -- Version pinning (V4) is left as a possible later addition, as a `requiredVersion` in `config.json`. For v1 it is not needed, because the hard stop already prevents the dangerous case. -- Storing only non-default values in `config.json` (from V2) remains possible on its own. It would reduce how many migrations touch user values, but users would no longer see the available options in the file. -- Not adopted: serving templates from the engine (V2 overlay), because agents other than Claude Code would no longer see every template as a file. diff --git a/docs/feature-packages/01-store-foundation-and-format-versioning.md b/docs/feature-packages/01-store-foundation-and-format-versioning.md index a5e1bed..50b0586 100644 --- a/docs/feature-packages/01-store-foundation-and-format-versioning.md +++ b/docs/feature-packages/01-store-foundation-and-format-versioning.md @@ -2,12 +2,12 @@ | | | |---|---| -| Status | Draft, 2026-10-14 | -| Source | Concept §3.1, §3.2, §4.1, §9; [ADR 0001](../adr/0001-shadekit-version-drift.md) | +| Status | Draft, 2026-10-14, updated 2026-10-18 (implementation state; package 02 implementation: missing `features/` is valid, exit codes 11, 13, 23; code review rework: exit codes 14, 24). Implemented except the integration versions in `version --json` (package 07). | +| Source | Concept §3.1, §3.2, §4.1, [§9](../../research/shade-kit-concept.md#9-version-drift) | | Depends on | — | | Enables | Every later package | -Labels follow the concept: **Decided**, **Proposed**, **Open**, **Unverified**. +Labels follow the concept: **Decided**, **Proposed**, **Open**, **Unverified**, **Implemented**. ## Benefit @@ -33,14 +33,15 @@ Every file the kit writes into a repository is safe across branch merges and kit ### Kit root -- The kit root is `.shadekit/` at the repository root. **Decided.** -- Commands find it by walking up from the working directory to the git repository root. A command that needs an initialised repository and finds no kit root stops with a clear message that points to `shade-kit init`. +- The kit root is `.shadekit/` at the repository root. **Decided** (concept §4.1). +- Commands find it by walking up from the working directory to the git repository root. A command that needs an initialised repository and finds no kit root stops with a clear message that points to `shade-kit init`. **Implemented** (`KitStoreFactory`, `GitRepositoryLocator`, `KitStorePaths`). +- A kit root without a `features/` directory is valid and has no features. **Decided** on 2026-10-18 (package 02 implementation clarification), because git does not keep the empty `features/` that `init` creates, so a fresh clone has none. The former exit code 11 (`features-dir-not-found`) is removed. **Implemented** (`KitStore.GetFeatureNames` returns an empty list). -### Versioned files (ADR 0001) +### Versioned files (concept §9.1, §9.4) -- Every JSON file in `.shadekit/` has a top-level integer `schemaVersion`. **Decided.** The version lives in the file it describes: every `features//feature.json` carries its own, so features created on different branches with different engine versions stay independent. **Decided.** -- Every JSON file also has a top-level `minEngineVersion` (semver string): the first engine version that supports the file's format version. The engine stamps it on every write from an internal table "format version → engine version that introduced it". An older engine reads it without understanding the rest of the format. **Decided.** -- Each file type has its own format version and its own migration chain of small steps (v1→v2→v3). **Decided.** File types are identified by these logical ids, all starting at format version 1. **Decided.** +- Every JSON file in `.shadekit/` has a top-level integer `schemaVersion`. **Decided** (concept §9.1). The version lives in the file it describes: every `features//feature.json` carries its own, so features created on different branches with different engine versions stay independent. **Decided** (concept §9.1). +- Every JSON file also has a top-level `minEngineVersion` (semver string): the first engine version that supports the file's format version. The engine stamps it on every write from an internal table "format version → engine version that introduced it". An older engine reads it without understanding the rest of the format. **Decided** (concept §9.1). **Implemented** (`VersionedDocument`, `VersionedJsonSerializer`). +- Each file type has its own format version and its own migration chain of small steps (v1→v2→v3). **Decided** (concept §9.1). **Implemented** (`FormatDefinition`, `IFormatMigration`). File types are identified by these logical ids, all starting at format version 1. **Decided** (implemented in code). **Implemented** (`FormatIds`, `FormatRegistry.CreateDefault()`). | Format id | File | |---|---| @@ -51,29 +52,29 @@ Every file the kit writes into a repository is safe across branch merges and kit | `templates-lock` | `.shadekit/templates.lock.json` | | `integrations` | User-level integration record (package 07) | -- Fields the engine does not know are kept unchanged when the file is written back. Adding a field never raises the format version. **Decided.** -- A file without `schemaVersion` is treated as invalid, not as version 1, and every command that touches it stops (`invalid-file`). **Decided**, because a missing version most likely means a hand-edited or foreign file. +- Fields the engine does not know are kept unchanged when the file is written back. Adding a field never raises the format version. **Decided** (concept §9.4). **Implemented** (`[JsonExtensionData]` in `VersionedDocument`). +- A file without `schemaVersion` is treated as invalid, not as version 1, and every command that touches it stops (`invalid-file`). **Decided** (concept §9.1), because a missing version most likely means a hand-edited or foreign file. **Implemented** (`InvalidKitFileException`). ### Version check on every command | Situation | Behaviour | |---|---| -| File format is newer than the engine supports | Hard stop (`format-too-new`), message "shade-kit >= X required" with X taken from the file's `minEngineVersion`. **Decided.** | -| File format is older than the engine's current format | Write commands stop and report that `shade-kit upgrade` is needed (`upgrade-needed`). **Decided.** Read commands follow `outdatedFormatOnRead` in `config.json` (package 02): `migrate-and-warn` (default) migrates in memory, returns the result, and adds an `upgrade-needed` warning; `stop` behaves like a write command. **Decided.** To read this setting, an outdated `config.json` is itself migrated in memory. | +| File format is newer than the engine supports | Hard stop (`format-too-new`), message "shade-kit >= X required" with X taken from the file's `minEngineVersion`. **Decided** (concept §9.2). | +| File format is older than the engine's current format | Write commands stop and report that `shade-kit upgrade` is needed (`upgrade-needed`). **Decided** (concept §9.2). Read commands follow `outdatedFormatOnRead` in `config.json` (package 02): `migrate-and-warn` (default) migrates in memory, returns the result, and adds an `upgrade-needed` warning; `stop` behaves like a write command. **Decided** (concept §9.2). To read this setting, an outdated `config.json` is itself migrated in memory. | | File format is current | Normal operation. | -The check covers only the files a command touches. A repository that contains files in several format versions after a merge is a valid state (ADR 0001). +**Implemented** (`KitStore`, `FormatTooNewException`, `UpgradeNeededException`, `OutdatedFormatOnReadPolicy`). The check covers only the files a command touches. A repository that contains files in several format versions after a merge is a valid state (concept §9). ### `shade-kit version` -- Plain output: the engine version. **Decided** (exists already). -- `--json` output: engine version and the current format version per file type, keyed by format id (`"formats": { "config": 1, … }`). This is the engine's capability, not the state of a repository. **Decided.** Skills use it for the version handshake (package 06), and package 07 adds the installed integration versions to it. +- Plain output: the engine version. **Decided** (implemented in code). **Implemented** (`VersionCommand`). +- `--json` output: engine version and the current format version per file type, keyed by format id (`"formats": { "config": 1, … }`). This is the engine's capability, not the state of a repository. **Decided** (implemented in code). **Implemented** (`VersionCommandData`). Skills use it for the version handshake (package 06), and package 07 adds the installed integration versions to it. ### Agent contract (applies to every agent-facing command) -- Every command an agent calls supports `--json`. **Decided.** -- JSON output goes to stdout. Warnings and errors in JSON mode are part of the JSON document, not free text on stderr. **Decided.** -- Every JSON output uses the same envelope. **Decided.** +- Every command an agent calls supports `--json`. **Decided** (concept §3.2). +- JSON output goes to stdout. Warnings and errors in JSON mode are part of the JSON document, not free text on stderr. **Decided** (implemented in code). +- Every JSON output uses the same envelope. **Decided** (implemented in code). ```json { @@ -84,18 +85,21 @@ The check covers only the files a command touches. A repository that contains fi } ``` - On failure, `ok` is `false`, `data` is `null`, and `error` is `{ "code": "…", "message": "…" }`. -- Every failure has a stable exit code from `ShadeKitCliExitCodes` and, in JSON mode, a stable error code string, so skills can branch on it without parsing messages. **Decided.** Error and warning codes are kebab-case. **Decided.** -- Exit codes are grouped in ranges per area: 10–19 store and kit root, 20–29 format and version, 30–39 features, 40–49 phases and gates, 255 host errors. **Decided.** Codes owned by packages 01 and 02 (the existing codes 1 and 2 move to 10 and 11): + On failure, `ok` is `false`, `data` is `null`, and `error` is `{ "code": "…", "message": "…" }`. **Implemented** (`CliJsonEnvelope`, `ICliJsonWriter`, `CliErrorMapper`). +- Every failure has a stable exit code from `ShadeKitCliExitCodes` and, in JSON mode, a stable error code string, so skills can branch on it without parsing messages. **Decided** (implemented in code). Error and warning codes are kebab-case. **Decided** (implemented in code). +- Exit codes are grouped in ranges per area: 10–19 store and kit root, 20–29 format and version, 30–39 features, 40–49 phases and gates, 255 host errors. **Decided** (implemented in code). Codes owned by packages 01 and 02, all **Implemented** (`ShadeKitCliExitCodes`, `ShadeKitCliErrorCodes`, `CliErrorMapper`). Exit code 11 is free since 2026-10-18 (see [Kit root](#kit-root)): | Exit code | Error code | Owner | |---|---|---| | 10 | `kit-root-not-found` | 01 | - | 11 | `features-dir-not-found` | 01 | | 12 | `not-a-git-repository` | 02 | + | 13 | `already-initialized` | 02 | + | 14 | `path-not-a-file` | 02 | | 20 | `format-too-new` | 01 | | 21 | `upgrade-needed` | 01 | | 22 | `invalid-file` | 01 | + | 23 | `invalid-managed-section` | 02 | + | 24 | `unsupported-encoding` | 02 | | 255 | `host-error` | 01 | - "Not found" or "not resolved" answers that are part of normal flow (for example no current feature) are results, not errors. **Proposed.** diff --git a/docs/feature-packages/02-repository-initialisation.md b/docs/feature-packages/02-repository-initialisation.md index ad80765..419a02e 100644 --- a/docs/feature-packages/02-repository-initialisation.md +++ b/docs/feature-packages/02-repository-initialisation.md @@ -2,11 +2,13 @@ | | | |---|---| -| Status | Draft, 2026-10-14 | -| Source | Concept §3.1 (`init`), §4.1, §9; [ADR 0001](../adr/0001-shadekit-version-drift.md) decision 5 | +| Status | Draft, 2026-10-14, updated 2026-10-17 (template conventions) and 2026-10-18 (implementation state; code review rework: exit codes 14 and 24, `.gitignore` rule, separator line). Implemented except the final content of the templates owned by later packages (skeletons until then), the `gates.json` defaults (package 05), and a possible Copilot instruction file (package 08). | +| Source | Concept §3.1 (`init`), §4.1, [§9](../../research/shade-kit-concept.md#9-version-drift) (§9.5) | | Depends on | 01 | | Enables | 03, 05, 18 and every phase package (they ship templates through this mechanism) | +Labels follow the concept: **Decided**, **Proposed**, **Open**, **Unverified**, **Implemented**. + ## Benefit A team can turn any git repository into a SHADE kit repository with one command. Afterwards the repository holds everything the team owns and may adapt (configuration, templates), and agents that open the repository learn from a short instruction section that the kit is in use. @@ -27,37 +29,97 @@ A team can turn any git repository into a SHADE kit repository with one command. - `gates.json` content and the checklist templates (package 05; `init` writes them through the same mechanism). - The final wording of each artifact template. Each phase package owns the content of its templates; this package ships the mechanism and the initial set. - Updating templates or the instruction section in an existing repository (package 18). -- Installing skills (packages 07, 08). Skills are never written into the repository (ADR 0001, **Decided**). +- Installing skills (packages 07, 08). Skills are never written into the repository (concept §9.6, **Decided**). ## Functional description ### `shade-kit init` -- Runs in a git repository. Outside a git repository it stops with an exit code. **Proposed**, because feature resolution (package 03) depends on git branches. +**Implemented** (`InitCommand`, `IRepositoryInitializer` / `RepositoryInitializer`). + +- Runs in a git repository. The repository root is the closest directory, from the working directory upwards, that contains a `.git` directory or file; `init` always works at that root, also when it is called from a subdirectory. Outside a git repository it stops with exit code 12 (`not-a-git-repository`) and creates nothing. **Decided** on 2026-10-18 (implementation clarification), because feature resolution (package 03) depends on git branches. **Implemented** (`IGitRepositoryLocator` / `GitRepositoryLocator`). - Creates `.shadekit/` with: - `config.json` - - `gates.json` (defaults from package 05; until package 05 is built, only `schemaVersion`) + - `gates.json` (defaults from package 05; until package 05 is built, only `schemaVersion` and `minEngineVersion`). **Implemented** (`GatesConfig`, no fields of its own yet). - `templates/` with every shipped template - `templates.lock.json` - - `features/` (empty) -- Adds `.gitignore` entries for `.shadekit/local.json` and `.shadekit/features/current-feature.json`. **Decided** in the concept tree. `local.json` itself is not created in v1, because nothing in v1 reads it (separate spec store is later). -- Adds or updates the managed SHADE kit section in `CLAUDE.md` and `AGENTS.md` (see below). -- In an already initialised repository, `init` changes nothing and reports that the repository is initialised and that `shade-kit upgrade` is the way to update. **Proposed.** -- Prints a summary of what it created. Supports `--json`. + - `features/` (empty). Git does not keep an empty directory, so a clone of a freshly initialised repository has no `features/`; a missing `features/` counts as "no features" (package 01). +- Adds `.gitignore` entries for `.shadekit/local.json` and `.shadekit/features/current-feature.json`. **Decided** in the concept tree. `local.json` itself is not created in v1, because nothing in v1 reads it (separate spec store is later). Rules in [`.gitignore`](#gitignore). **Implemented** (`GitIgnorePlanner`). +- Adds or updates the managed SHADE kit section in `CLAUDE.md` and `AGENTS.md` (see [below](#managed-instruction-section)). **Implemented** (`InstructionSectionPlanner`). +- In an already initialised repository, `init` changes nothing, stops with exit code 13 (`already-initialized`), and points to `shade-kit upgrade` as the way to update. The repository counts as initialised when a `.shadekit` directory (also an empty one) exists at its root. **Decided** on 2026-10-18 (implementation clarification). **Implemented** (`KitAlreadyInitializedException`). +- A `.shadekit` file at the repository root is neither initialised nor free: `init` and every command that opens the store (for example `feature list`) stop with exit code 15 (`kit-root-not-a-directory`) and ask to move or remove the file and run `init`. **Decided** on 2026-10-19 (code review rework); before, `init` treated the file as initialised and `feature list` as missing, so each command pointed to the other. **Implemented** (`KitRootNotADirectoryException`, `RepositoryInitializer`, `KitStoreFactory.Create`). +- Prints a summary of what it created or changed. Supports `--json`. Output contract in [Output](#output). **Implemented** (`InitCommandData`, `InitializedFile`). + +### Order of work and failures + +`init` works in this order. **Decided** on 2026-10-18 (implementation clarification). **Implemented** (`RepositoryInitializer`). + +1. Preconditions, before anything is written: + 1. the git repository root (exit code 12); + 2. no `.shadekit` file (exit code 15) and no existing `.shadekit` directory (exit code 13); + 3. `.gitignore`, then `CLAUDE.md`, then `AGENTS.md` at the repository root, each checked in this order: the path is not a directory (exit code 14), the file neither starts with a UTF-16 or UTF-32 byte order mark nor contains a NUL byte (exit code 24), and, for `CLAUDE.md` and `AGENTS.md`, the section markers are valid (exit code 23). A missing file passes. **Decided** on 2026-10-18 (code review rework) for the directory and encoding checks; the NUL byte check on 2026-10-19. **Implemented** (`IGitIgnorePlanner.PlanAsync`, `IInstructionSectionPlanner.PlanAsync`, `TextFileContent`): planning computes the new content of all three files before anything is written, so the checks and the writes use the same code. +2. `.shadekit/`: `config.json`, `gates.json`, the templates in the order of the template table, `templates.lock.json`, `features/`. +3. `.gitignore`. +4. `CLAUDE.md`, then `AGENTS.md`. + +The first failing precondition stops `init`; nothing is written. A cancellation is checked again right before the first write, and `.shadekit/` is created only with its first file (`config.json`), so a cancelled `init` leaves no `.shadekit/` behind that would block the next run. **Implemented** (`RepositoryInitializer`, `InitializeAsync_TokenCanceledDuringPreconditions_ThrowsOperationCanceledExceptionAndWritesNothing`). + +If a write fails after the preconditions, for example because of an IO error, or `init` is cancelled while it writes, `init` removes the `.shadekit/` it created in this run and fails (a host error, exit code 255, for an IO error). `.shadekit/` did not exist before, so nothing is lost. `.gitignore`, `CLAUDE.md`, or `AGENTS.md` written before the failure stay in place; a second `init` recognises their changes (entries present, section identical) and does not repeat them, so running `init` again after fixing the cause completes the initialisation. Removing `.shadekit/` is best effort: if it fails, `.shadekit/` stays and a second `init` stops with exit code 13. **Decided** on 2026-10-19 by the user (code review rework), replacing "no rollback" from 2026-10-18, which left a `.shadekit/` behind that blocked every later `init` and pointed to the not yet existing `shade-kit upgrade`. **Implemented** (`RepositoryInitializer`, `InitializeAsync_LastWriteFails_RemovesKitRootAndKeepsRepositoryFilesWrittenBefore`, `InitializeAsync_AfterLastWriteFailed_SecondRunCompletesOnlyTheMissingChanges`, `InitializeAsync_WriteInKitRootFails_RemovesKitRootAndWritesNoRepositoryFile`, `InitializeAsync_CanceledWhileWritingKitRoot_RemovesKitRoot`). Known failures that the preconditions do not catch are listed in the [open points](#open-points). + +### Output + +**Decided** on 2026-10-18 (implementation clarification). **Implemented** (`InitCommand`, `InitCommandData`). + +- `--json`: the `data` of the envelope (package 01) lists the repository root and every file or directory that was created or changed, in write order: + + ```json + { + "repositoryRoot": "/home/dev/src/shop", + "files": [ + { "path": ".shadekit/config.json", "action": "created" }, + { "path": ".shadekit/gates.json", "action": "created" }, + { "path": ".shadekit/templates/request.md", "action": "created" }, + { "path": ".shadekit/templates.lock.json", "action": "created" }, + { "path": ".shadekit/features/", "action": "created" }, + { "path": ".gitignore", "action": "appended" }, + { "path": "CLAUDE.md", "action": "updated" }, + { "path": "AGENTS.md", "action": "created" } + ] + } + ``` + + The example shortens the template list to one entry. `repositoryRoot` is an absolute path. `path` is relative to the repository root with `/` as separator; a directory ends with `/`. `action` is `created`, `appended`, or `updated`. A file that already had the required content is not listed. +- Plain: `Initialised SHADE kit in .`, one line ` ` per file, and a hint to commit `.shadekit/` and the changed files together with the code. + +### Errors + +Exit codes owned by this package. **Implemented** (`ShadeKitCliExitCodes`, `ShadeKitCliErrorCodes`, `CliErrorMapper`). + +| Exit code | Error code | When | Files changed | +|---|---|---|---| +| 12 | `not-a-git-repository` | The working directory is not inside a git repository. | None | +| 13 | `already-initialized` | `.shadekit` already exists at the repository root. The message points to `shade-kit upgrade`. | None | +| 14 | `path-not-a-file` | `.gitignore`, `CLAUDE.md`, or `AGENTS.md` at the repository root is a directory (`PathNotAFileException`). The message names the path. **Decided** on 2026-10-18 (code review rework). | None | +| 15 | `kit-root-not-a-directory` | `.shadekit` at the repository root is a file (`KitRootNotADirectoryException`). Also returned by every command that opens the store. The message names the path and points to `shade-kit init`. **Decided** on 2026-10-19 (code review rework). | None | +| 23 | `invalid-managed-section` | The section markers in `CLAUDE.md` or `AGENTS.md` are invalid (see [below](#managed-instruction-section)). The message names the file and the problem. | None | +| 24 | `unsupported-encoding` | `.gitignore`, `CLAUDE.md`, or `AGENTS.md` starts with a UTF-16 LE, UTF-16 BE, UTF-32 LE, or UTF-32 BE byte order mark, or contains a NUL byte, which UTF-16 and UTF-32 without byte order mark have for every ASCII character (`UnsupportedFileEncodingException`); UTF-8 with or without byte order mark is accepted. The message names the file and its encoding, `UTF-16 or UTF-32 without byte order mark` for a file with a NUL byte. **Decided** on 2026-10-18 (code review rework), the NUL byte check on 2026-10-19. | None | + +Any other failure, for example a file that cannot be read or written, is not mapped and ends with exit code 255 (`host-error`). ### `config.json` (v1) -The team owns the values; the engine owns the schema (ADR 0001). **Decided.** v1 content, **Decided**: +The team owns the values; the engine owns the schema. **Decided** (concept §9.1). v1 content: **Proposed**, except `schemaVersion`, `minEngineVersion`, and `outdatedFormatOnRead` (**Decided**, concept §9.1–9.2) and `report` (**Decided** on 2026-10-18). **Implemented** (`KitConfig`, `ReportPolicy`): every field below is a typed property; fields the engine does not know are kept (package 01). | Field | Purpose | |---|---| | `schemaVersion` | Format version (package 01). | | `minEngineVersion` | First engine version that supports this format version, stamped by the engine on every write (package 01). | -| `kitVersion` | Kit version that initialised or last upgraded the repository. Informational only; format checks use `schemaVersion`. | +| `kitVersion` | Kit version that initialised or last upgraded the repository. Informational only; format checks use `schemaVersion`. `init` writes the engine version. | | `defaultProfile` | Profile used by `feature new` when none is given. Default `feature`. | | `outdatedFormatOnRead` | What read commands do with a file in an older format (package 01): `migrate-and-warn` (default) or `stop`. | -| `trackers` | Mapping from issue URL host to id prefix for feature ids. Default: `github.com` → `gh` (package 03). | +| `trackers` | Mapping from issue URL host to id prefix for feature ids. Default: `github.com` → `gh` (package 03), which also applies when the field is missing; `{}` turns issue-based ids off, `null` makes the file invalid. **Decided** on 2026-10-19 (code review rework). **Implemented** (`KitConfig.Trackers`). | | `coverageThreshold` | Optional. Minimum coverage that `verify` requires (package 14). Not written by `init`; absent means "measure and record only". | +| `report` | Whether `finish` writes `report.md` (package 15): `ask` (default, `finish` asks the human), `always`, or `never`. Absent means `ask`. **Decided** on 2026-10-18 (findings clarification). | `config.json` as written by `init` (version strings depend on the release): @@ -68,51 +130,146 @@ The team owns the values; the engine owns the schema (ADR 0001). **Decided.** v1 "kitVersion": "", "defaultProfile": "feature", "outdatedFormatOnRead": "migrate-and-warn", - "trackers": { "github.com": "gh" } + "trackers": { "github.com": "gh" }, + "report": "ask" } ``` ### Templates -- Shipped templates are copied into `.shadekit/templates/` as plain files. Agents other than Claude Code need them as files in the repository (ADR 0001). **Decided.** -- No engine code parses the structure of a template or of an artifact created from it (ADR 0001). **Decided.** Every check against artifact content is done by an agent. -- Initial set, owned by the listed packages. Until its owner package is built, a template is a skeleton with headings only; templates change freely until the first release, so this causes no lock-file churn for users. +- Shipped templates are copied into `.shadekit/templates/` as plain files. Agents other than Claude Code need them as files in the repository (concept §9.5). **Decided.** **Implemented** (`RepositoryInitializer`). +- No engine code parses the structure of a template or of an artifact created from it (concept §9.5). **Decided.** Every check against artifact content is done by an agent. +- Initial set, owned by the listed packages. Until its owner package is built, a template is a skeleton with headings only; templates change freely until the first release, so this causes no lock-file churn for users. **Implemented:** `spec`, `plan`, `tasks`, and the two checklists come from the template interview (2026-10-17); `request`, `discussion`, `researches`, `review`, `verification`, `playbook`, `report`, and `diagnosis` are skeletons whose guidance comment names the owner package. +- In the kit's source, every template is a file `.template.md` in `src/CreativeCoders.ShadeKit.Core/Templates/Resources/`, so it cannot be mistaken for an artifact. The source folder has the same subfolders as `.shadekit/templates/` (the checklists are in `checks/`). The files are embedded into the Core assembly as resources named `templates//.template.md` (for example `templates/spec.template.md`, `templates/checks/definition-of-ready.template.md`), with `/` on every OS, so the `dotnet` tool and the single-file binaries carry them without extra files. **Decided** on 2026-10-18 (implementation clarification). **Implemented** (`EmbeddedResource` in `CreativeCoders.ShadeKit.Core.csproj`, `IShippedTemplateReader` / `ShippedTemplateReader`). +- `init` writes each template to its path in the repository, given in the table below. The engine keeps this mapping as an explicit list; it does not derive paths from file names. **Decided** on 2026-10-18 (implementation clarification). **Implemented** (`ShippedTemplate`, `ShippedTemplates.All`). Paths in `templates.lock.json` are repository paths. +- Every template is written as UTF-8 without byte order mark, with LF line endings: every line ending in the source (CRLF, a lone CR, and the other line endings `string.ReplaceLineEndings` recognises) becomes LF, whatever line endings the checkout of the kit's source used. **Decided** on 2026-10-18 (implementation clarification). **Implemented** (`ShippedTemplateReader`, `RepositoryInitializer`). -| Template | Owner package | -|---|---| -| `request.md` | 09 | -| `discussion.md` | 09 | -| `spec.md` | 10 | -| `researches.md` | 06 (shared format), sections filled in 10 and 11 | -| `plan.md`, `tasks.md` | 12 | -| `review.md` | 13 | -| `verification.md`, `playbook.md` | 14 | -| `report.md` | 15 | -| `diagnosis.md` | 16 | -| `checks/dor.md`, `checks/dod.md` | 05 | +| Template in the repository (`.shadekit/templates/`) | Source in the kit (`src/CreativeCoders.ShadeKit.Core/Templates/Resources/`) | Owner package | +|---|---|---| +| `request.md` | `request.template.md` | 09 | +| `discussion.md` | `discussion.template.md` | 09 | +| `spec.md` | `spec.template.md` | 10 | +| `researches.md` | `researches.template.md` | 06 (shared format), sections filled in 10 and 11 | +| `plan.md`, `tasks.md` | `plan.template.md`, `tasks.template.md` | 12 | +| `review.md` | `review.template.md` | 13 | +| `verification.md`, `playbook.md` | `verification.template.md`, `playbook.template.md` | 14 | +| `report.md` | `report.template.md` | 15 | +| `diagnosis.md` | `diagnosis.template.md` | 16 | +| `checks/definition-of-ready.md`, `checks/definition-of-done.md` | `checks/definition-of-ready.template.md`, `checks/definition-of-done.template.md` | 05 | + +### Template conventions + +Every template follows the same conventions. **Decided** in the template interview on 2026-10-17. + +- **Guidance for the agent** is written in HTML comments (``), one per template and one per section where needed. Every agent reads the same file, so the rules work the same in Claude Code, Copilot, and Codex, and teams that edit a template see the rules next to the structure. +- **Examples** appear only inside a guidance comment, at most one per section, so they cannot be left behind as content. +- **Placeholders** are written `{{name}}`; a choice of values is written `{{a | b}}`, with the pipe escaped as `\|` inside table cells. Leftover placeholders can be found by a plain text search. +- **Header:** a title, the feature id, and relative links to the related artifacts. No status or phase: `feature.json` is the only source of the feature state. +- **IDs** are stable and never renumbered: `REQ`, `AC`, `A`, `Q` (package 10), `D`, `T` (package 12), `F` (package 13), `DOR`, `DOD` (package 05). +- When an artifact is filled in, every placeholder is replaced and every guidance comment is removed. Checklist copies (package 05) are the exception: they keep their guidance comment, because they are rechecked on every run. ### `templates.lock.json` -- One entry per shipped template: path, kit version, and content hash at install time. **Decided** (ADR 0001). +- One entry per shipped template: path, kit version, and content hash at install time. **Decided** (concept §9.5). **Implemented** (`TemplatesLock`, `TemplatesLockEntry`). - `init` writes it; package 18 uses it to tell changed from unchanged templates. +- Shape: **Decided** on 2026-10-18 (implementation clarification). + + ```json + { + "schemaVersion": 1, + "minEngineVersion": "", + "templates": [ + { + "path": ".shadekit/templates/checks/definition-of-done.md", + "kitVersion": "", + "hash": "sha256:<64 lowercase hex digits>" + } + ] + } + ``` + + - `path` is the repository path with `/` as separator. Entries are sorted ordinally by `path`. + - `kitVersion` is the engine version that wrote the template. + - `hash` is `sha256:` followed by the SHA-256 of the exact bytes written to the file (UTF-8 without byte order mark, LF line endings), as 64 lowercase hex digits. + - Unknown fields are kept at the top level and in every entry (package 01). + +### `.gitignore` + +**Decided** on 2026-10-18 (implementation clarification; presence rule and separator line changed in the code review rework the same day). **Implemented** (`IGitIgnorePlanner` / `GitIgnorePlanner`; the entries are `KitGitIgnoreEntries.All`). + +- The file is `.gitignore` at the repository root. If it does not exist, `init` creates it with the comment line and both entries. +- An entry counts as present when a line, with its trailing spaces removed, equals the entry or the entry with a leading `/`. This follows git, which ignores trailing spaces of a pattern but treats leading whitespace and trailing tabs as part of it: a line with leading whitespace or a trailing tab does not count, and neither does a line ending in an escaped space (`\ `), because the escaped space stays part of the pattern. Patterns that only match the files indirectly, such as `*.json`, do not count either. +- Missing entries are appended without a leading `/`, under the comment line `# SHADE kit: local-only files`. The comment is written only when something is appended, after one blank line if the file has content and does not already end with an empty line. If nothing is missing, the file is not changed. +- In an existing file every existing byte stays unchanged: a missing final line break is added before appending, a leading UTF-8 byte order mark is kept, and the appended lines use CRLF if the file contains CRLF. A new file is UTF-8 without byte order mark, with LF. A file that starts with a UTF-16 or UTF-32 byte order mark or contains a NUL byte is not changed (exit code 24). ### Managed instruction section -- A short section in `CLAUDE.md` and `AGENTS.md`, delimited by begin and end markers so the engine can find and replace exactly that section later. **Proposed.** -- Content: the repository uses SHADE kit, the kit data lives in `.shadekit/`, how to start (the router skill from package 06), and that the engine is called as `shade-kit … --json`. -- If a file does not exist, `init` creates it with only the section. If it exists, `init` appends the section and leaves the rest unchanged. +- A short section in `CLAUDE.md` and `AGENTS.md` at the repository root, delimited by begin and end markers so the engine can find and replace exactly that section later. **Decided** on 2026-10-18 (implementation clarification). **Implemented** (`IInstructionSectionPlanner` / `InstructionSectionPlanner`; the text and the markers are `ManagedInstructionSection.Text`, `.BeginMarkerPrefix`, and `.EndMarker`). +- Content: the repository uses SHADE kit, the kit data lives in `.shadekit/`, how to start (`shade-kit-next-phase` from package 06), and that the engine is called as `shade-kit … --json`. The text `init` writes: + + ```markdown + + ## SHADE kit + + This repository uses SHADE kit. Its data lives in `.shadekit/` (configuration, templates, features) and is committed with the code. + + - To start or continue work on a feature, use the `shade-kit-next-phase` skill. + - Call the engine as `shade-kit --json` and act on its JSON result. + - Change feature state only through `shade-kit` commands, never by editing `.shadekit/features/` by hand. + + ``` + +- Markers: the begin marker is a line whose text, without leading and trailing whitespace, starts with ``, so ``. Lines in a fenced code block are no markers, so a file can show the markers in a code example: a block starts with a line of at least three backticks or tildes, indented by at most three spaces (a backtick fence must not have a backtick in its info string), and ends with a line of only the same character, at least as many times, or at the end of the file (CommonMark). A block that is still open at the end of the file makes `init` stop with exit code 23, because a section appended after it would be part of the block and would be appended again by every later `init`. The markers are invalid when a file has more than one begin or end marker, a begin marker without an end marker, an end marker without a begin marker, or the end marker before the begin marker. **Decided** on 2026-10-19 (code review rework) for the code blocks, the unclosed block, and the word end of the begin marker. **Implemented** (`InstructionSectionPlanner`, `PlanAsync_MarkerLinesInCodeFenceOrNotMarkers_AppendsSectionAndKeepsThem`, `PlanAsync_SectionOutsideCodeFences_ReplacesOnlyThatSection`). +- Behaviour per file: + + | File | `init` | Reported action | + |---|---|---| + | Does not exist | Creates it with only the section. | `created` | + | Exists, no markers | Appends one blank line and the section; no blank line is added if the file already ends with an empty line. The rest stays unchanged. | `appended` | + | Exists, exactly one valid pair of markers | Replaces the lines from the begin marker to the end marker with the section; the rest stays unchanged. | `updated`; not listed if the section was already identical | + | Exists, invalid markers | Stops with exit code 23 (`invalid-managed-section`). Both files are checked before anything is written, so no file is changed. | — | + +- In an existing file every byte outside the inserted or replaced part stays unchanged: a missing final line break is added before appending, a leading UTF-8 byte order mark is kept, and the section uses CRLF if the file contains CRLF. A new file is UTF-8 without byte order mark, with LF. A file that starts with a UTF-16 or UTF-32 byte order mark or contains a NUL byte is not changed (exit code 24). +- `AGENTS.md` as a symbolic link to `CLAUDE.md` works: the section is written once into `CLAUDE.md`, the link is kept, and `AGENTS.md` is not listed, because a link to a file that is already planned is skipped. **Implemented** (`InitializeAsync_AgentsMdIsSymlinkToClaudeMd_AppendsExactlyOneSectionAndKeepsSymlink` and `InitializeAsync_AgentsMdIsSymlinkToClaudeMdWithOutdatedSection_UpdatesSectionOnceAndKeepsSymlink`, skipped where symbolic links cannot be created). - **Unverified:** whether GitHub Copilot reads `AGENTS.md` or needs `.github/copilot-instructions.md`. Package 08 resolves this and may add a third target file. +### Deviations from the draft + +The implementation on 2026-10-18 changed these statements of the earlier draft. All were decided by the user in the implementation clarification on 2026-10-18. + +| Draft | Now | Reason | +|---|---|---| +| "If it exists, `init` appends the section and leaves the rest unchanged." | A file that already has a valid section gets it replaced, not appended a second time. | Appending to a file that has the section would break FP02-AC5 ("exactly one managed section") and contradicted "Adds or updates" in the `init` list. | +| Template sources in `templates/` at the repository root (`templates/.template.md`, `templates/checks/`). | Sources in `src/CreativeCoders.ShadeKit.Core/Templates/Resources/`, embedded into the Core assembly. | The engine reads the templates from its own assembly, so both distribution forms carry them. | +| `features/` is created empty (no statement about a missing one); package 01's code stopped with exit code 11 (`features-dir-not-found`) when it was missing. | A missing `features/` counts as empty. Exit code 11 and `features-dir-not-found` are removed; 11 is free. | Git does not keep the empty `features/` that `init` creates, so every fresh clone of an initialised repository would have failed. | +| `gates.json` with "only `schemaVersion`" until package 05. | `schemaVersion` and `minEngineVersion`. | Package 01 stamps `minEngineVersion` on every JSON file the engine writes (FP01-AC9). | + +### Review + +The implementation was reviewed on 2026-10-18 ([review report](../reviews/2026-10-18-feature-cli-init-repo-uncommitted.md)). Findings #1–#7 and #9–#11 are fixed; #8 (`AllSatisfy` instead of a loop in the FP02-AC2 test) was accepted as is by the user. + ## Acceptance criteria -- **FP02-AC1** `init` in a git repository without `.shadekit/` creates `config.json`, `gates.json`, `templates/` with every shipped template, `templates.lock.json`, and `features/`. -- **FP02-AC2** Every JSON file `init` writes carries `schemaVersion`. -- **FP02-AC3** `templates.lock.json` contains one entry per shipped template whose hash matches the written file. -- **FP02-AC4** `.gitignore` contains the entries for `local.json` and `current-feature.json` exactly once, also when `init` runs in a repository whose `.gitignore` already has them. -- **FP02-AC5** `CLAUDE.md` and `AGENTS.md` contain exactly one managed section; existing content outside the section is unchanged. -- **FP02-AC6** `init` in an initialised repository changes no file and points to `shade-kit upgrade`. -- **FP02-AC7** `init` outside a git repository stops with an exit code and creates nothing. +Every criterion is **Implemented**. The tests that prove it carry `[Trait("Requirement", "FP02-ACn")]`. + +- **FP02-AC1** `init` in a git repository without `.shadekit/` creates `config.json`, `gates.json`, `templates/` with every shipped template, `templates.lock.json`, and `features/`. Proven by `RepositoryInitializerTests`, `InitCommandTests`, `InitCommandEndToEndTests`. +- **FP02-AC2** Every JSON file `init` writes carries `schemaVersion`. Proven by `RepositoryInitializerTests`. +- **FP02-AC3** `templates.lock.json` contains one entry per shipped template whose hash matches the written file. Proven by `RepositoryInitializerTests`. +- **FP02-AC4** `.gitignore` contains the entries for `local.json` and `current-feature.json` exactly once, also when `init` runs in a repository whose `.gitignore` already has them. Proven by `GitIgnorePlannerTests`, `RepositoryInitializerTests`, `InitCommandEndToEndTests`. +- **FP02-AC5** `CLAUDE.md` and `AGENTS.md` contain exactly one managed section; existing content outside the section is unchanged. Proven by `InstructionSectionPlannerTests`, `RepositoryInitializerTests`, `InitCommandEndToEndTests`. +- **FP02-AC6** `init` in an initialised repository changes no file and points to `shade-kit upgrade`. Proven by `RepositoryInitializerTests`, `InitCommandTests`, `InitCommandEndToEndTests`, `CliErrorMapperTests`. +- **FP02-AC7** `init` outside a git repository stops with an exit code and creates nothing. Proven by `RepositoryInitializerTests`, `InitCommandTests`, `InitCommandEndToEndTests`. +- **FP02-AC8** `init` writes every shipped template to its repository path from the template table, for example `spec.template.md` to `.shadekit/templates/spec.md` and `checks/definition-of-ready.template.md` to `.shadekit/templates/checks/definition-of-ready.md`. Proven by `RepositoryInitializerTests`, `InitCommandEndToEndTests`, and `ShippedTemplatesTests` (`All_Fp02Ac8Examples_MapToRepositoryPathFromTemplateTable`, without the trait). ## Open points -- None beyond the unverified Copilot instruction file (resolved in package 08). +- The Copilot instruction file is **Unverified** (resolved in package 08). +- Follow-ups found during the implementation on 2026-10-18, all **Open**: + - Plain output is wrapped at 80 columns by Spectre.Console when stdout is not a terminal, so long paths in the plain output of `init` and `feature list` are split across lines. JSON output is not affected. + - The shared enum converter of `VersionedJsonSerializer` (package 01) accepts comma-separated values such as `"always, never"` and reads them as a combined value that is not a defined member, for example in `report` and `outdatedFormatOnRead`. + - The skeleton templates name their owner package in their guidance comment, so that text is copied into every repository. Each owner package must replace its skeleton when it is built. + - If `.gitignore` already contains the comment line `# SHADE kit: local-only files` but an entry is missing, `init` appends a second comment line before the missing entry. +- Follow-ups found in the code review rework on 2026-10-18, all **Open** (left open by the user on 2026-10-18): + - A dangling symbolic link, for example `AGENTS.md` pointing to a missing `CLAUDE.md`, or a `.gitignore` pointing to a missing file, makes `init` fail during the preconditions with an unmapped `FileNotFoundException`: exit code 255 (`host-error`), nothing written. Checked on macOS with the built CLI on 2026-10-18 for both cases. + - A read-only `.gitignore`, `CLAUDE.md`, or `AGENTS.md` passes the preconditions and fails only when it is written: exit code 255 (`host-error`). Since 2026-10-19, `init` then removes the `.shadekit/` it created, so a second `init` after making the file writable completes the initialisation (checked on macOS with the built CLI on 2026-10-19 with a read-only `444` `CLAUDE.md`). A file that cannot be read fails already in the preconditions with exit code 255 and nothing written. Checked on macOS with the built CLI on 2026-10-18 with an unreadable (`000`) `CLAUDE.md`. + - The CLI messages for exit codes 14, 23, and 24 tell the user to run `shade-kit init` again. Package 18 reuses the planners (`IGitIgnorePlanner`, `IInstructionSectionPlanner`), so these messages need wording that does not name one command. diff --git a/docs/feature-packages/03-feature-management.md b/docs/feature-packages/03-feature-management.md index 059d43c..c90aef5 100644 --- a/docs/feature-packages/03-feature-management.md +++ b/docs/feature-packages/03-feature-management.md @@ -2,7 +2,7 @@ | | | |---|---| -| Status | Draft, 2026-10-14 | +| Status | Draft, 2026-10-14, updated 2026-10-18 (`feature drop` confirmation, `status` in `feature current`, `feature list` implemented) | | Source | Concept §3.1, §3.2, §4.1–4.3, §5.1 (`new`, `drop`), §11.4 | | Depends on | 01, 02 | | Enables | 04 and every phase package | @@ -30,9 +30,9 @@ Several developers can create features in parallel on their own branches without ### Feature ids -- No sequential numbers. **Decided.** -- With an issue: `--`, for example `gh-142-login-sso`. The prefix comes from the `trackers` mapping in `config.json` (package 02). **Decided** (format), **Proposed** (mapping). -- Without an issue: `--`, for example `20261013-1530-login-sso`. **Decided.** +- No sequential numbers. **Decided** (concept §4.2). +- With an issue: `--`, for example `gh-142-login-sso`. The prefix comes from the `trackers` mapping in `config.json` (package 02). **Decided** (format, concept §4.2), **Proposed** (mapping). +- Without an issue: `--`, for example `20261013-1530-login-sso`. **Decided** (concept §4.2). - The slug is built from the title: lower case, ASCII, words joined by `-`, limited to a short length. **Proposed.** - If the id already exists, `new` stops with an exit code. It does not add a suffix silently, because an existing id usually means the feature was already created on another branch. **Proposed.** @@ -52,15 +52,15 @@ Feature `status`: `active` or `done`. **Proposed.** A dropped feature has no sta | Command | Behaviour | |---|---| | `feature new --profile

--source [--title ]` | Creates the folder, `feature.json`, and `request.md`. The profile defaults to `defaultProfile` from `config.json`. A URL whose host is in `trackers` gives an issue-based id; the number comes from the URL, the slug from `--title`. Any other source gives a time-based id. For a file source, the file content is copied into `request.md`. Writes the local override so the new feature is current right away. Returns the id and paths with `--json`. **Proposed** details. | -| `feature list [--json]` | Lists features with id, title, profile, status, and (once package 04 exists) the current phase. Exists already; extended with the new fields. | -| `feature current [--json]` | Returns the current feature: id, profile, artifact paths, and (once package 04 exists) current phase and phase status. If none can be resolved, returns "not resolved" as a result, not as an error (package 01 contract). | +| `feature list [--json]` | Lists features with id, title, profile, status, and (once package 04 exists) the current phase. **Implemented** with feature names only (`FeatureListCommand`); extended with the new fields. | +| `feature current [--json]` | Returns the current feature: id, profile, feature `status` (`active` or `done`), artifact paths, and (once package 04 exists) current phase and phase status. If none can be resolved, returns "not resolved" as a result, not as an error (package 01 contract). | | `feature set ` | Writes `features/current-feature.json`. Stops if the id does not exist. | | `feature show [--json]` | Returns the complete state of a feature, including the approval history from package 04. Needed by the report in package 15. **Proposed.** | -| `feature drop --yes` | Deletes the feature folder and clears the local override if it points to this feature. Possible in every phase. Without `--yes` it only describes what it would delete. The agent asks the human before it passes `--yes`. **Proposed** (confirmation). | +| `feature drop [--yes]` | Deletes the feature folder and clears the local override if it points to this feature. Possible in every phase. With `--yes` it deletes at once. Without `--yes` it first describes what it would delete; then, only in plain mode (no `--json`) with an interactive standard input, it asks for a confirmation in the console and deletes after a yes. In JSON mode or with redirected standard input it stops after the description and deletes nothing, so an agent can never hang on the question. An agent calls it with `--json`: first without `--yes` to get the description it shows the human, and after the human's yes in the conversation with `--yes`. **Decided** on 2026-10-18. | ### Resolving the current feature -Order (**Decided**): +Order (**Decided**, concept §4.3): 1. The current branch name. The id is the part after the last `/` (`feature/gh-142-login-sso` → `gh-142-login-sso`). It counts only if a feature folder with that id exists. **Proposed** (matching rule). 2. The local override `features/current-feature.json`. @@ -83,8 +83,10 @@ The engine does not create or switch branches in v1. `feature new` returns the s - **FP03-AC5** On branch `feature/` with an existing folder ``, `feature current` returns ``, also when the local override points to another feature; the output then contains a warning. - **FP03-AC6** Without a matching branch and without an override, `feature current --json` returns "not resolved" with exit code 0. - **FP03-AC7** `feature set` with an unknown id stops with an exit code. -- **FP03-AC8** `feature drop ` without `--yes` deletes nothing; with `--yes` it deletes the folder and clears an override that points to it. +- **FP03-AC8** `feature drop --json` without `--yes` deletes nothing and returns what it would delete; with `--yes` it deletes the folder and clears an override that points to it. - **FP03-AC9** `feature.json` records the git user as `createdBy`. +- **FP03-AC10** `feature drop ` in plain mode with an interactive standard input and without `--yes` asks for a confirmation and deletes only after a yes; with redirected standard input it deletes nothing and does not wait for input. +- **FP03-AC11** `feature current --json` returns the feature `status`, and `done` after `phase complete` (package 04). ## Open points diff --git a/docs/feature-packages/04-phase-engine-and-workflow-profiles.md b/docs/feature-packages/04-phase-engine-and-workflow-profiles.md index 128b80a..f72adfc 100644 --- a/docs/feature-packages/04-phase-engine-and-workflow-profiles.md +++ b/docs/feature-packages/04-phase-engine-and-workflow-profiles.md @@ -2,7 +2,7 @@ | | | |---|---| -| Status | Draft, 2026-10-14 | +| Status | Draft, 2026-10-14, updated 2026-10-18 (`phase reject`, re-entry, `quick` phase; `phase next`, pending transition on `phase start `) | | Source | Concept §3.2, §5.1, §5.3, §5.4, §11.2–11.4 | | Depends on | 01, 03 | | Enables | 05 and every phase package | @@ -17,8 +17,8 @@ The engine is the referee of the workflow: it knows each feature's phase, allows - The three v1 profiles `feature`, `bugfix`, `quick` as built-in definitions. - The phase state machine and the phase fields of `feature.json`. -- `phase start`, `phase approve`, `phase complete`, `phase skills`. -- Forward transitions, resuming the current phase, and reopening an earlier phase. +- `phase start`, `phase approve`, `phase reject`, `phase complete`, `phase skills`, `phase next`. +- Forward transitions, re-entering a phase that is in progress, rejecting a pending transition, and reopening an earlier phase. - The approval history. ### Out of scope @@ -32,17 +32,18 @@ The engine is the referee of the workflow: it knows each feature's phase, allows ### Profiles -A profile is an ordered list of phases plus its gate defaults. One phase engine runs every profile. **Decided.** +A profile is an ordered list of phases plus its gate defaults. One phase engine runs every profile. **Decided** (concept §5.4). | Profile | Phases | |---|---| | `feature` | `new` → `refine` → `map` → `plan` → `implement` → `verify` → `finish` | | `bugfix` | `new` → `diagnose` → `plan` → `implement` → `verify` → `finish` | -| `quick` | `new` → `implement` → `finish` | +| `quick` | `new` → `quick` → `finish` | -- Each profile maps every phase to the skill that runs it (`shade-kit-`, **Proposed**). Skills adapt their behaviour to the profile they read from `feature current` (for example `plan` is short in `bugfix`). +- `quick` is a phase of its own: clarification, compact spec, implementation, and review in one run (package 17). **Decided** on 2026-10-18. +- Every phase runs through the skill named after it (`shade-kit-`, **Proposed** names); with the `quick` phase this holds for every phase of every profile. Skills adapt their behaviour to the profile they read from `feature current` (for example `plan` is short in `bugfix`). - The profile of a feature is fixed at `feature new` and cannot change in v1. **Proposed.** -- `discuss` and `drop` are not phases of any profile. `discuss` needs no feature state (package 09), `drop` is `feature drop` (package 03). **Decided.** +- `discuss` and `drop` are not phases of any profile. `discuss` needs no feature state (package 09), `drop` is `feature drop` (package 03). **Decided** (findings clarification, 2026-10-18). ### Phase states @@ -55,7 +56,7 @@ States per phase (**Proposed** in the concept, adopted): `pending` → `in-progr | `currentPhase` | The phase that is in progress or awaiting approval. | | `phases` | Map phase → state, one entry per phase of the profile. | | `pendingTransition` | Target phase while the current phase is `awaiting-approval`, otherwise absent. | -| `history` | List of entries: time, git user, action (`start`, `approve`, `resume`, `reopen`, `complete`), from phase, to phase, optional note. | +| `history` | List of entries: time, git user, action (`start`, `approve`, `reject`, `reopen`, `complete`), from phase, to phase, optional note. | `feature new` sets `new` to `in-progress` and every other phase to `pending`. @@ -63,22 +64,39 @@ States per phase (**Proposed** in the concept, adopted): `pending` → `in-progr | Command | Allowed when | Effect | |---|---|---| -| `phase start --json` | `` is the phase that follows `currentPhase` in the profile | Current phase → `awaiting-approval`, `pendingTransition` = ``. Returns: transition allowed, entry gate (approval required, checklist path or none), skills to load. | +| `phase start --json` | `` is the phase that follows `currentPhase` in the profile, and the current phase is `in-progress` | Current phase → `awaiting-approval`, `pendingTransition` = ``. Returns: transition allowed, entry gate (approval required, checklist path or none), skills to load. | +| `phase start --json` (repeated) | `pendingTransition` is already `` | Changes nothing and returns the same gate again, for example in a new session. No `history` entry. **Decided** on 2026-10-18 (findings clarification). | +| `phase start --json` (re-entry) | `` is `currentPhase` and `in-progress` | Changes nothing and returns "already in progress" with no gate and the skills to load, so a phase skill can be called again in a new session or for another `refine` round. No `history` entry. **Decided** on 2026-10-18. | +| `phase start --json` | `` is `currentPhase` and `awaiting-approval` | Changes nothing and returns "transition to `` pending" with no gate and the skills to load. The phase skill asks the human whether to discard the pending transition and, on yes, calls `phase reject ` (package 06). No `history` entry. **Decided** on 2026-10-18 (findings clarification). | | `phase approve ` | `pendingTransition` is `` | Records the approval in `history`. Current phase → `approved`, `` → `in-progress`, `currentPhase` = ``. | -| `phase start --json` | The current phase is `awaiting-approval` | Cancels the pending transition, the phase goes back to `in-progress` (gate failed, changes requested, another `refine` round). Recorded as `resume`. | -| `phase start --json` | `` comes before `currentPhase` in the profile | Like a forward transition, but needs an approval and is recorded as `reopen`. On approval, `` → `in-progress` and every phase after it → `pending`. Their artifacts stay on disk. **Proposed.** | +| `phase reject [--note ]` | `pendingTransition` is `` | Cancels the pending transition after the human said no; the current phase goes back to `in-progress` (gate failed, changes requested). Recorded as `reject` with the note, for example the failed checklist items. **Decided** on 2026-10-18. | +| `phase start --json` | `` comes before `currentPhase` in the profile, and the current phase is `in-progress` | Like a forward transition, but needs an approval and is recorded as `reopen`. On approval, `` → `in-progress` and every phase after it → `pending`. Their artifacts stay on disk. A "no" is a `phase reject `. **Proposed.** | | `phase complete` | The current phase is the last phase of the profile (`finish`) and `in-progress` | Last phase → `approved`, feature `status` → `done`, and the local override (package 03) is cleared if it points to this feature. No approval, because the transition into `finish` was approved already. **Proposed.** | Every other request (skipping a phase, a phase that is not in the profile, any transition on a `done` feature) stops with an exit code and a reason the skill can show to the human. -The diagram in concept §11.3 stays valid: `pending → in-progress` happens through `phase approve`, `in-progress → awaiting-approval` through `phase start `. +The diagram in concept §11.3 stays valid: `pending → in-progress` happens through `phase approve`, `in-progress → awaiting-approval` through `phase start `, and `awaiting-approval → in-progress` through `phase reject`. + +Because every phase skill calls `phase start ` as the first step of its entry protocol (package 06), the same call covers five situations: moving forward, repeating a pending transition, re-entering a phase in progress, resuming a phase whose transition is pending, and reopening an earlier phase. ### Approval -- Every transition needs an explicit human approval in v1. Package 05 adds switching it off per transition. **Decided.** -- The agent asks the human, and only after the human says yes does it call `phase approve`. **Decided.** +- Every transition needs an explicit human approval in v1. Package 05 adds switching it off per transition. **Decided** (concept §5.2). +- The agent asks the human, and only after the human says yes does it call `phase approve`. **Decided** (concept §3.2). - **Known weak point (Decided, accepted for v1):** nothing technically stops an agent from calling `phase approve` on its own. Later options: `approve` only in an interactive terminal, or a hook that blocks the call. +### `phase next --json` + +Returns the possible next steps of the current feature, so `shade-kit-next-phase` (package 06) does not derive them from the profile. **Decided** on 2026-10-18 (findings clarification). It changes nothing. + +| Field | Content | +|---|---| +| `current` | Current phase and its state, and the feature status. For a `done` feature this is the only field. | +| `continue` | Present when the current phase is `in-progress`: the phase and its skill. | +| `pending` | Present when the current phase is `awaiting-approval`: the target phase, its gate (approval, checklist), and its skill. | +| `forward` | The next phase of the profile with its gate and skill, or absent in the last phase. | +| `reopen` | List of earlier phases with their skills. A reopen always needs an approval. | + ### `phase skills --json` Returns the phase skill for the feature's profile and the general skills it needs (package 06). The same list is part of the `phase start` result. Extension skills are added here later (concept §7). @@ -94,11 +112,15 @@ Returns the phase skill for the feature's profile and the general skills it need - **FP04-AC3** `phase start` with a phase that is not next, not current, and not earlier stops with an exit code; `feature.json` is unchanged. - **FP04-AC4** `phase approve` without a matching pending transition stops with an exit code. - **FP04-AC5** `phase approve` records time, git user, from, and to in `history` and moves `currentPhase`. -- **FP04-AC6** `phase start ` while awaiting approval returns the phase to `in-progress` and removes the pending transition. +- **FP04-AC6** `phase reject ` while the transition to `` is pending returns the current phase to `in-progress`, removes the pending transition, and records `reject` with the note; without a matching pending transition it stops with an exit code. - **FP04-AC7** An approved reopen of an earlier phase sets every later phase to `pending` and keeps their artifact files. - **FP04-AC8** `phase complete` in `finish` sets the feature to `done`; in any other phase it stops with an exit code. -- **FP04-AC9** `bugfix` contains `diagnose` and no `refine` or `map`; `quick` contains only `new`, `implement`, `finish`. +- **FP04-AC9** `bugfix` contains `diagnose` and no `refine` or `map`; `quick` contains only `new`, `quick`, `finish`. - **FP04-AC10** `phase skills` returns a different phase skill list per profile where the profiles differ. +- **FP04-AC11** `phase start ` while the current phase is `in-progress` leaves `feature.json` byte-for-byte unchanged and returns "already in progress" without a gate. +- **FP04-AC12** `phase start ` while the transition to `` is already pending returns the same gate again and adds no `history` entry. +- **FP04-AC13** `phase start ` while the transition away from `` is pending leaves `feature.json` unchanged and returns the pending transition without a gate. +- **FP04-AC14** `phase next --json` returns `continue`, `forward`, and `reopen` for a phase `in-progress`, `pending` for a phase `awaiting-approval`, no `forward` in the last phase, and only `current` for a `done` feature; `feature.json` is unchanged. ## Open points diff --git a/docs/feature-packages/05-gates-and-checklists.md b/docs/feature-packages/05-gates-and-checklists.md index 86997d5..2ad29f3 100644 --- a/docs/feature-packages/05-gates-and-checklists.md +++ b/docs/feature-packages/05-gates-and-checklists.md @@ -2,7 +2,7 @@ | | | |---|---| -| Status | Draft, 2026-10-14 | +| Status | Draft, 2026-10-14, updated 2026-10-17 (checklist format and default items) and 2026-10-18 (`quick` phase; DOR-10, DOD-01, items that do not apply) | | Source | Concept §2 (Gates), §4.1 (`checks/`), §5.2, §6, §11.1, §11.4 | | Depends on | 02, 04 | | Enables | 12 (DoR), 15 (DoD), every phase transition | @@ -16,14 +16,14 @@ A team decides per repository how strict the workflow is: which transitions need ### In scope - `gates.json`: shape, defaults per profile, switching approval and checklist on or off. -- The checklist templates `checks/dor.md` and `checks/dod.md` and their default content. +- The checklist templates `checks/definition-of-ready.md` and `checks/definition-of-done.md` and their default content. - Creating the feature's own copy of a checklist when its phase starts. - The gate information in the `phase start` result (package 04). - The rule for checklist items that do not apply to a profile. ### Out of scope -- Checking a checklist. The agent does that with the checklist-check skill (package 06). The engine does not parse checklists (ADR 0001, package 02). +- Checking a checklist. The agent does that with the checklist-check skill (package 06). The engine does not parse checklists (concept §9.5, package 02). - Quality checks run by the CLI and check commands configured in `gates.json` (later, concept §6). - Approval enforcement (later, package 04 "Known weak point"). @@ -31,17 +31,17 @@ A team decides per repository how strict the workflow is: which transitions need ### Gate model -- A gate belongs to a transition and is identified by its target phase. **Decided.** -- It is checked when the next phase starts. **Decided.** -- It has two independent switches: `approval` (a human must approve) and `checklist` (path of a checklist the agent must check first, or none). **Decided** that each gate can be switched off; switching off approval and checklist independently is the concept's **Proposed** answer to open question 4, adopted here. -- DoR is the checklist of the gate into `plan`. DoD is the checklist of the gate into `finish`. All other gates have no checklist by default. **Decided.** -- Any transition can get a checklist by pointing its gate to a `checks/.md` template. **Decided** as an extension point. +- A gate belongs to a transition and is identified by its target phase. **Decided** (concept §5.2). +- It is checked when the next phase starts. **Decided** (concept §5.2). +- It has two independent switches: `approval` (a human must approve) and `checklist` (path of a checklist the agent must check first, or none). **Decided** (concept §5.2) that each gate can be switched off; switching off approval and checklist independently answers concept open question 4, **Decided** (confirmation list, 2026-10-18). +- DoR is the checklist of the gate into `plan`. DoD is the checklist of the gate into `finish`. All other gates have no checklist by default. **Decided** (concept §5.2). +- Any transition can get a checklist by pointing its gate to a `checks/.md` template. **Decided** as an extension point (concept §5.2). ### `gates.json` Written by `init` (package 02), owned by the team for its values, versioned like every JSON file (package 01). -The concept's example is keyed by phase only. Because the `quick` profile needs different defaults than `feature` (one approval at the end), gates are keyed by profile and then by target phase. **Proposed**, deviation from the concept example. +The concept's example is keyed by phase only. Because the `quick` profile needs different defaults than `feature` (one approval at the end), gates are keyed by profile and then by target phase. **Decided** (confirmation list, 2026-10-18); the concept now describes this shape. ```json { @@ -50,66 +50,96 @@ The concept's example is keyed by phase only. Because the `quick` profile needs "feature": { "refine": { "approval": true }, "map": { "approval": true }, - "plan": { "approval": true, "checklist": "checks/dor.md" }, + "plan": { "approval": true, "checklist": "checks/definition-of-ready.md" }, "implement": { "approval": true }, "verify": { "approval": true }, - "finish": { "approval": true, "checklist": "checks/dod.md" } + "finish": { "approval": true, "checklist": "checks/definition-of-done.md" } }, "bugfix": { "diagnose": { "approval": true }, - "plan": { "approval": true, "checklist": "checks/dor.md" }, + "plan": { "approval": true, "checklist": "checks/definition-of-ready.md" }, "implement": { "approval": true }, "verify": { "approval": true }, - "finish": { "approval": true, "checklist": "checks/dod.md" } + "finish": { "approval": true, "checklist": "checks/definition-of-done.md" } }, "quick": { - "implement": { "approval": false }, - "finish": { "approval": true, "checklist": "checks/dod.md" } + "quick": { "approval": false }, + "finish": { "approval": true, "checklist": "checks/definition-of-done.md" } } } } ``` -- A transition with no entry uses `{ "approval": true }`. Approval is the safe default. **Proposed.** +- A transition with no entry uses `{ "approval": true }`. Approval is the safe default. **Decided** (confirmation list, 2026-10-18). - With `approval: false`, `phase start` performs the transition at once and records it in `history` with the note "approval off". **Proposed.** -- With `approval: false` and a `checklist`, the checklist copy is still created and checked by the agent, but a failed item is only reported as a warning; it does not block, because nobody is asked to decide. An agent never approves on its own instead. **Proposed.** -- The defaults follow concept §5.2: every transition needs an approval, except the `quick` transition into `implement` (concept §5.4: "one approval at the end"). +- With `approval: false` and a `checklist`, the checklist copy is still created and checked by the agent, but a failed item is only reported as a warning; it does not block, because nobody is asked to decide. An agent never approves on its own instead. **Decided** (confirmation list, 2026-10-18). +- The defaults follow concept §5.2: every transition needs an approval, except the `quick` profile's transition into the `quick` phase (concept §5.4: "one approval at the end"). ### Checklists - The team edits the templates in `.shadekit/templates/checks/`. They are tracked by `templates.lock.json` like every template (package 02). -- When a phase with a checklist starts, the engine copies the template into the feature folder (`features//checks/dor.md`) if the copy does not exist yet, and returns its path. The agent checks and fills in this copy. The filled copy is the record of the gate. **Proposed.** -- A reopened phase (package 04) reuses the existing copy, so earlier results stay visible; the agent rechecks every item. +- When a phase with a checklist starts, the engine copies the template into the feature folder (`features//checks/definition-of-ready.md`) if the copy does not exist yet, and returns its path. The agent checks and fills in this copy. The filled copy is the record of the gate. **Proposed.** +- A reopened phase (package 04) reuses the existing copy; the agent rechecks every item, and the "Check runs" log keeps the earlier results visible. -Default content (**Proposed** in the concept, adopted): +### Checklist format -| DoR (gate into `plan`) | DoD (gate into `finish`) | +**Decided** in the template interview on 2026-10-17; the general template conventions are in package 02. + +- One table per checklist: `ID | Criterion | Result | Evidence`. +- Item IDs are `DOR-nn` and `DOD-nn`. Teams may add, change, or remove items; existing IDs stay stable. +- `Result` is `pending` in the template and becomes `pass`, `fail`, or `n/a` (see "Items that do not apply"). A table is used instead of checkboxes because a checkbox cannot express "not applicable". +- `Evidence` names what was checked, with IDs, paths, or command results; for `fail`, what is missing. +- On every run the agent overwrites `Result` and `Evidence` and adds one line to the "Check runs" section: date and time, `pass` or `fail`, and the failed item IDs. + +### Default content + +**Decided** in the template interview on 2026-10-17. It contains the four items per checklist proposed in concept §5.2 (DOR-01, DOR-04, DOR-06, DOR-10; DOD-01/02, DOD-03, DOD-05, DOD-09) and adds the rest. + +| ID | Definition of Ready (gate into `plan`) | +|---|---| +| DOR-01 | Every requirement in `spec.md` has an ID (`REQ-nnn`) and at least one acceptance criterion. | +| DOR-02 | Every acceptance criterion can be decided by an automated test, or is marked "(manual)" with a reason. | +| DOR-03 | Every requirement has a priority: must, should, or could. | +| DOR-04 | `spec.md` has no open questions: "Open questions" says "None." and no `(open: Q-nnn)` marker is left. | +| DOR-05 | `spec.md` has no template leftovers: no `{{placeholder}}` and no guidance comment. | +| DOR-06 | "Out of scope" is stated, also when nothing is excluded. | +| DOR-07 | Every assumption is confirmed by a human or marked as a risk. | +| DOR-08 | Every edge case has an acceptance criterion or is listed in "Out of scope". | +| DOR-09 | "Security considerations" lists the findings, or says "None identified." with a reason. | +| DOR-10 | The affected areas are known: `map` is done, was skipped on purpose with a recorded reason, or `diagnosis.md` lists the affected code. | + +| ID | Definition of Done (gate into `finish`) | |---|---| -| Every requirement has an ID and at least one acceptance criterion | Build, test, lint, and coverage checks pass | -| No open questions left in `spec.md` | Every acceptance criterion is covered by a passing test | -| Out of scope is stated | `review.md` has no open blockers | -| Affected areas known (`map` done, or skipped on purpose) | Every item in `playbook.md` is checked off by a human | +| DOD-01 | Build, test, and lint pass with the quality commands recorded in `review.md`, as recorded in `verification.md`. | +| DOD-02 | Coverage was measured and recorded in `verification.md`. If `config.json` sets `coverageThreshold`, the value reaches it. | +| DOD-03 | Every acceptance criterion is covered by a passing test, or is marked manual and has an item in `playbook.md`. | +| DOD-04 | Every task in `tasks.md` is done, or has a "Not done" reason that the human accepted. | +| DOD-05 | `review.md` has no open blocker. | +| DOD-06 | The quality bar was not lowered: no new analyzer or lint suppressions, skipped or deleted tests, or weakened assertions without a reason accepted in `review.md`. | +| DOD-07 | Every deviation from the plan is recorded in `plan.md` with a reason, and every migration or rollout step is done or handed over. | +| DOD-08 | The documentation matches the change (for example README, changelog, API docs), or Evidence states why no update is needed. | +| DOD-09 | Every item in `playbook.md` is checked off by a human. | ### Items that do not apply -A checklist item that refers to a phase or artifact the feature's profile does not have is marked "not applicable" with the reason, and does not fail the gate. Examples: "`map` done" in `bugfix` (`diagnose` covers affected areas), "`playbook.md`" in `quick`. **Proposed.** This keeps one DoR and one DoD for all profiles instead of a checklist per profile. +A checklist item that refers to a phase or artifact the feature's profile does not have is marked `n/a` with the reason in `Evidence`, and does not fail the gate. Examples: DOD-04 (`tasks.md`) and DOD-07 (`plan.md`) in `quick`, and DOD-09 (`playbook.md`) in `quick` when no criterion is manual (package 17). In `bugfix`, DOR-10 is not `n/a`: it passes on the "affected code" section of `diagnosis.md` (package 16). **Proposed.** This keeps one DoR and one DoD for all profiles instead of a checklist per profile. ### `phase start` result -The gate part of the result contains: target phase, `approval` (true or false), `checklist` (path in the feature folder, or none), and whether the transition was already performed because approval is off. +The gate part of the result contains: target phase, `approval` (true or false), `checklist` (path in the feature folder, or none), whether the transition was already performed because approval is off, and whether the phase was already in progress (re-entry, package 04; then there is no gate). ## Acceptance criteria -- **FP05-AC1** `init` writes `gates.json` with the defaults above and `checks/dor.md` and `checks/dod.md` with the default items. -- **FP05-AC2** `phase start plan` in a `feature` creates `features//checks/dor.md` from the template and returns its path. +- **FP05-AC1** `init` writes `gates.json` with the defaults above and `checks/definition-of-ready.md` and `checks/definition-of-done.md` with the default items. +- **FP05-AC2** `phase start plan` in a `feature` creates `features//checks/definition-of-ready.md` from the template and returns its path. - **FP05-AC3** A second `phase start plan` after a reopen keeps the existing feature checklist unchanged. - **FP05-AC4** With `approval: false` for a transition, `phase start` performs the transition and records it as "approval off" in `history`. - **FP05-AC5** Without a `checklist` entry, `phase start` returns no checklist and creates no file. - **FP05-AC6** A transition missing from `gates.json` requires approval. -- **FP05-AC7** In a new `quick` feature, `phase start implement` moves to `implement` without approval, and `phase start finish` asks for approval with the DoD checklist. +- **FP05-AC7** In a new `quick` feature, `phase start quick` moves to `quick` without approval, and `phase start finish` asks for approval with the DoD checklist. - **FP05-AC8** A team-defined `checks/.md` referenced in `gates.json` is copied and returned for that transition. +- **FP05-AC9** The shipped checklist templates contain the default items above, each with `Result` `pending`, and an empty "Check runs" section. ## Open points -- The deviation from the concept's `gates.json` example (keyed by profile) needs confirmation. -- Coverage threshold for the DoD item "coverage checks pass" (package 14). +- The value of the coverage threshold. DOD-02 checks `coverageThreshold` from `config.json` only when it is set (packages 02 and 14). diff --git a/docs/feature-packages/06-skill-foundation.md b/docs/feature-packages/06-skill-foundation.md index cae910b..3893b82 100644 --- a/docs/feature-packages/06-skill-foundation.md +++ b/docs/feature-packages/06-skill-foundation.md @@ -2,14 +2,14 @@ | | | |---|---| -| Status | Draft, 2026-10-14 | -| Source | Concept §3.2, §3.3, §3.4, §11.4; [ADR 0001](../adr/0001-shadekit-version-drift.md) decision 6 | +| Status | Draft, 2026-10-14, updated 2026-10-17 (template conventions, checklist format) and 2026-10-18 (no router: `shade-kit-next-phase` and entry protocol; findings clarification: `phase next`, pending transition, report and review skills) | +| Source | Concept §3.2, §3.3, §3.4, §11.4, [concept §9.6](../../research/shade-kit-concept.md#9-version-drift) | | Depends on | 01, 03, 04, 05 | | Enables | 07, 08, and every phase package (09–17) | ## Benefit -A developer only has to say "continue" (or start a new feature) and the agent finds the current feature, its phase, and the next step, runs the gate check, asks for approval, and hands off to the right phase skill. Every phase skill builds on the same small set of shared skills, so interviews, gate checks, research, and reports work the same way everywhere. +A developer only has to say "continue" (or start a new feature) and the agent finds the current feature, its phase, and the possible next steps, and starts the phase skill the human chooses. That phase skill runs the gate check and asks for approval itself, so a phase is gated also when its skill is called directly. Every phase skill builds on the same small set of shared skills, so interviews, gate checks, research, and reports work the same way everywhere. ## Scope @@ -17,7 +17,8 @@ A developer only has to say "continue" (or start a new feature) and the agent fi - Conventions every kit skill follows. - The version handshake on the skill side. -- The router skill. +- The entry protocol every phase skill runs. +- `shade-kit-next-phase`, the entry point that offers the next steps. - The general skills: interview, checklist check, research orchestration, report writing. - The shared format of `researches.md`. @@ -31,44 +32,59 @@ A developer only has to say "continue" (or start a new feature) and the agent fi ### Skill conventions -- Skills are written in the shared `SKILL.md` format and the plugin layout of `skills/`. Claude-only features (for example subagents) are only used where an agent-neutral way would lose function, and each such use is named in the skill. **Decided** (constraint), **Unverified** (format compatibility, resolved in package 08). +- Skills are written in the shared `SKILL.md` format and the plugin layout of `skills/`. Claude-only features (for example subagents) are only used where an agent-neutral way would lose function, and each such use is named in the skill. **Decided** (constraint, concept §1), **Unverified** (format compatibility, resolved in package 08). - Skills never read or write `feature.json`, `gates.json`, or `config.json` directly. They use the CLI with `--json`. Skills read and write Markdown artifacts in the feature folder directly. **Proposed.** This keeps every state change behind the engine's version check (package 01). -- Every artifact a skill creates starts from the repository's template in `.shadekit/templates/`, not from text built into the skill, so team edits to templates take effect. **Proposed.** +- A shipped skill references no path under `research/` or `docs/` of the kit repository; those folders are design notes and are not shipped. The source comments of the skeletons are removed when a skill is worked out. **Decided** on 2026-10-18 (findings clarification). +- Every artifact a skill creates starts from the repository's template in `.shadekit/templates/`, not from text built into the skill, so team edits to templates take effect. **Proposed.** The skill follows the template conventions of package 02: it follows the guidance comments, replaces every placeholder, and removes the guidance comments, except in checklist copies. ### Version handshake -- Before it runs, a kit skill calls `shade-kit version --json` and compares the engine version with its own kit version. **Decided** (ADR 0001). +- Before it runs, a kit skill calls `shade-kit version --json` and compares the engine version with its own kit version. **Decided** (concept §9.6). - Match rule: major and minor must be equal, patch may differ. **Proposed.** - On mismatch, or if `shade-kit` is not found, the skill stops and tells the human what to update (engine or integration) and how. -### Router skill +### Entry protocol -The entry point for the human (`shade-kit`, **Proposed** name). +Every phase skill runs it as its first step, so a direct call such as `/shade-kit-plan` is gated too. **Decided** in the skills interview on 2026-10-17 ([shade-kit-skills.md](../../research/shade-kit-skills.md)); steps 2 and 4 extended on 2026-10-18. `shade-kit-new` has no entry protocol, because `feature new` creates the feature with `new` already in progress (package 04). 1. Version handshake. -2. `shade-kit feature current --json`. If not resolved: list features (`feature list --json`), ask which one or offer to create a new one, then `feature set` or hand off to `new` (package 09). -3. Read profile, current phase, and phase state, and decide: +2. `shade-kit phase start --json`. If the transition is not allowed, stop and show the reason. If the phase is already in progress (a new session, another `refine` round), the engine changes nothing and returns no gate (package 04): continue with step 5. If a transition away from this phase is pending, the engine changes nothing and reports the pending transition (package 04): the skill asks the human whether to discard it; on yes it calls `phase reject --note "resumed "` and continues with step 5, on no it hands off to `shade-kit-next-phase`. +3. If the gate has a checklist, run the checklist check skill on it. +4. Gate decision: + - Approval on: show the result and ask the human for an explicit approval, also when the check passed. On yes: `phase approve `. On no: `phase reject --note ""`; the previous phase is in progress again, and the skill hands off to `shade-kit-next-phase` with the reported gaps. + - Approval off: the engine has already performed the transition (package 05). A failed checklist is reported as a warning and does not block. +5. Load the skills the CLI returned and run the phase. -| State of the current phase | Router action | -|---|---| -| `in-progress` | Hand off to the phase skill of the current phase. | -| work of the phase is done, human wants to continue | `phase start --json`, then the gate dialogue below. | -| `awaiting-approval` | Repeat the gate dialogue for the pending transition. | -| feature `done` | Report that the feature is finished; offer to pick another feature. | +A phase skill never approves without a human answer in the conversation. **Decided** (concept §3.2). + +### `shade-kit-next-phase` + +The entry point for the human when they say "continue" or ask where a feature stands. It only chooses and hands off: it never runs a gate check and never calls `phase approve`. **Decided** in the skills interview on 2026-10-17. -4. **Gate dialogue:** if the gate's approval is off, the engine has already performed the transition (package 05); the router runs the checklist check if the gate has a checklist, reports the result as a warning, and hands off. Otherwise: if the gate has a checklist, run the checklist check skill on it. Show the result. Ask the human for an explicit approval, also when the check passed. On yes: `phase approve `, then load the returned skills and hand off. On no: `phase start ` (resume) and continue in the current phase with the reported gaps. -5. Also handles "reopen phase X", "drop this feature" (`feature drop`, after explicit confirmation), and "discuss" (package 09). +1. Version handshake. +2. `shade-kit feature current --json`. If not resolved: list features (`feature list --json`), ask which one or offer to create a new one, then `feature set` or hand off to `shade-kit-new` (package 09). +3. `shade-kit phase next --json` (package 04) and offer the possible next steps it returns. The skill does not derive them from the profile. + +| State | `phase next` returns | Offered | +|---|---|---| +| feature `done` | only the status | Report that the feature is finished; offer to pick another feature. | +| current phase `in-progress` | `continue`, `forward` | Continue the current phase, or move on to the next phase of the profile. | +| current phase `awaiting-approval` | `pending` | Repeat the pending transition (the phase skill of the pending phase runs its entry protocol again). | +| current phase `in-progress`, earlier phases exist | `reopen[]` | Reopen an earlier phase. | +| any state of an active feature | | Always offered as well: `shade-kit-discuss`, `shade-kit-drop`. | -The router never approves without a human answer in the conversation. **Decided.** +4. Let the human choose, with the agent's structured question tool where one exists. +5. Start the phase skill of the chosen phase. Its entry protocol performs the transition: forward, re-entry, or reopen (package 04). ### General skills | Skill | Purpose | Used by | |---|---|---| -| Interview | Asks open questions one topic at a time, offers its own proposals, records answers in the target artifact. Uses the agent's structured question tool where one exists, plain questions otherwise. | `discuss`, `refine`, `diagnose`, `plan` | -| Checklist check | Checks every item of a feature checklist against the artifacts, marks each item passed, failed (with reason), or not applicable (package 05 rule), and writes the result into the checklist copy. | Router (every gate with a checklist) | -| Research | Runs a set of research focuses, one subagent per focus in parallel where the agent supports it, sequentially otherwise, and merges the results into `researches.md`. | `refine`, `map` | -| Report | Writes a Markdown report from a template and from data the CLI returns. | `verify`, `finish` | +| Interview | Asks open questions one topic at a time, offers its own proposals, records answers in the target artifact. Uses the agent's structured question tool where one exists, plain questions otherwise. | `discuss`, `refine`, `diagnose`, `plan`, `quick` | +| Checklist check | Checks every item of a feature checklist against the artifacts, sets its `Result` to `pass`, `fail`, or `n/a` (package 05 rule) with the `Evidence`, and adds a line to the "Check runs" log of the checklist copy (format in package 05). | Entry protocol of every phase skill (every gate with a checklist) | +| Research | Runs a set of research focuses, one subagent per focus in parallel where the agent supports it, sequentially otherwise, and merges the results into `researches.md`. | `refine`, `map`, `diagnose` | +| Report | Writes `report.md` from its template and from data the CLI returns. Optional: only runs when `finish` writes a report (package 15). | `finish` | +| Review | Independent code review with finding severities. Skill and `review.md` format are owned by package 13; listed here because more than one phase uses it. | `implement`, `quick` | ### `researches.md` @@ -79,13 +95,18 @@ The router never approves without a human answer in the conversation. **Decided. ## Acceptance criteria - **FP06-AC1** With an engine whose minor version differs from the skill's, every kit skill stops before doing any work and names what to update. -- **FP06-AC2** With no resolvable current feature, the router lists the features and offers to create a new one. -- **FP06-AC3** For a feature in `in-progress`, the router hands off to the phase skill that `phase skills` returns. -- **FP06-AC4** The router asks for approval also when the checklist passed, and calls `phase approve` only after a yes in the conversation. -- **FP06-AC5** On a no, the router calls `phase start ` and the feature is back in `in-progress`. +- **FP06-AC2** With no resolvable current feature, `shade-kit-next-phase` lists the features and offers to create a new one. +- **FP06-AC3** For a feature whose current phase is `in-progress`, `shade-kit-next-phase` offers to continue it, and the phase skill then continues without a gate. +- **FP06-AC4** The entry protocol asks for approval also when the checklist passed, and calls `phase approve` only after a yes in the conversation. +- **FP06-AC5** On a no, the phase skill calls `phase reject `, the previous phase is back in `in-progress`, and `shade-kit-next-phase` takes over. - **FP06-AC6** The checklist check marks an item that refers to an artifact the profile does not have as not applicable with a reason. - **FP06-AC7** The research skill writes one section per focus into `researches.md` and leaves sections of other phases unchanged. - **FP06-AC8** No kit skill writes a kit JSON file directly; a review of the skill texts finds only CLI calls for state changes. +- **FP06-AC9** `shade-kit-next-phase` never runs a gate check and never calls `phase approve`. +- **FP06-AC10** A phase skill called directly, without `shade-kit-next-phase`, runs the entry protocol before any phase work. +- **FP06-AC11** A phase skill called while a transition away from its phase is pending calls `phase reject` only after the human agreed to discard the pending transition. +- **FP06-AC12** `shade-kit-next-phase` offers exactly the steps `phase next --json` returns, plus `shade-kit-discuss` and `shade-kit-drop`. +- **FP06-AC13** No shipped skill references a path under `research/` or `docs/`. ## Open points diff --git a/docs/feature-packages/07-claude-code-integration.md b/docs/feature-packages/07-claude-code-integration.md index 827918e..34b2489 100644 --- a/docs/feature-packages/07-claude-code-integration.md +++ b/docs/feature-packages/07-claude-code-integration.md @@ -3,7 +3,7 @@ | | | |---|---| | Status | Draft, 2026-10-14 | -| Source | Concept §2 (Lifecycle), §3.1 (`integrations install/update`), §3.4; [ADR 0001](../adr/0001-shadekit-version-drift.md) decision 6 | +| Source | Concept §2 (Lifecycle), §3.1 (`integrations install/update`), §3.4, [concept §9.6](../../research/shade-kit-concept.md#9-version-drift) | | Depends on | 01, 06 | | Enables | 08 (same mechanism for more agents); end-to-end use of all phase packages in Claude Code | @@ -23,19 +23,19 @@ A developer installs the kit's skills and agents for Claude Code with one comman ### Out of scope - GitHub Copilot and OpenAI Codex CLI (package 08). -- Hooks and MCP configuration. The concept lists them as possible integration content, but no v1 feature needs them (approval enforcement and the MCP server are later). **Proposed.** -- Writing anything into a repository (ADR 0001, **Decided**). +- Hooks and MCP configuration. The concept lists them as possible integration content, but no v1 feature needs them (approval enforcement and the MCP server are later). **Decided** (confirmation list, 2026-10-18). +- Writing anything into a repository (concept §9.6, **Decided**). - Removing an integration. `integrations uninstall` is a later addition. **Proposed.** ## Functional description ### Content source -- The integration content (skills from package 06 and the phase packages, research agent definitions) is packed into the engine release, so an engine always installs exactly the content of its own version. **Proposed.** This makes "plugin and CLI release together" (ADR 0001) hold by construction on every machine. +- The integration content (skills from package 06 and the phase packages, research agent definitions) is packed into the engine release, so an engine always installs exactly the content of its own version. **Decided** (confirmation list, 2026-10-18). This makes "plugin and CLI release together" (concept §9.6) hold by construction on every machine. ### `integrations install --agent claude` -- Installs the content at **user level** as a Claude Code plugin. **Decided.** +- Installs the content at **user level** as a Claude Code plugin. **Decided** (concept §3.1). - **Unverified:** the exact way to register a plugin at user level from a CLI (for example a local plugin marketplace plus a plugin install command, or writing to the user's plugin directory). This has to be checked against the Claude Code documentation before the package is built. If no supported way exists, the fallback is writing the same content to the user's `~/.claude/` skills and agents directories. **Decided** as the general fallback in the concept §3.1. - Records the installed agent, kit version, and install location in an engine-owned file in the user's configuration directory (`schemaVersion` like every kit JSON file). **Proposed.** This file is outside every repository, so `upgrade` (package 18) does not touch it; `integrations install` and `integrations update` migrate it when its format is older. - Running it again for an installed agent behaves like `integrations update`. @@ -53,7 +53,7 @@ A developer installs the kit's skills and agents for Claude Code with one comman ## Acceptance criteria -- **FP07-AC1** After `integrations install --agent claude`, a new Claude Code session in any repository can load the router skill. +- **FP07-AC1** After `integrations install --agent claude`, a new Claude Code session in any repository can load `shade-kit-next-phase`. - **FP07-AC2** The installed skills have the same kit version as the engine that installed them. - **FP07-AC3** After an engine update, `integrations update` replaces the Claude Code content and reports old and new version. - **FP07-AC4** `shade-kit version --json` lists the installed Claude Code integration with its kit version. diff --git a/docs/feature-packages/08-copilot-and-codex-integration.md b/docs/feature-packages/08-copilot-and-codex-integration.md index 0188973..e96b493 100644 --- a/docs/feature-packages/08-copilot-and-codex-integration.md +++ b/docs/feature-packages/08-copilot-and-codex-integration.md @@ -3,7 +3,7 @@ | | | |---|---| | Status | Draft, 2026-10-14 | -| Source | Concept §1 (Success, Constraint), §2 (Agents), §3.4, §10 open question 5; [ADR 0001](../adr/0001-shadekit-version-drift.md) | +| Source | Concept §1 (Success, Constraint), §2 (Agents), §3.4, §10 open question 5, [concept §9](../../research/shade-kit-concept.md#9-version-drift) | | Depends on | 02, 06, 07 | | Enables | Phase packages 09–17 are built and accepted on all three agents | @@ -34,7 +34,7 @@ Before any integration work, check for GitHub Copilot and OpenAI Codex CLI (**Un | Question | Why it matters | |---|---| | Can it load the `SKILL.md` format and the plugin layout unchanged? | Decides whether one skill source serves all agents. | -| Does it have a plugin mechanism, and how is it installed at user level? | Decides between plugin install and writing to the user directory. **Decided** fallback: user directory. | +| Does it have a plugin mechanism, and how is it installed at user level? | Decides between plugin install and writing to the user directory. **Decided** fallback (concept §3.1): user directory. | | Does it support subagents, and can they run in parallel? | The research skill (package 06) runs one subagent per focus; without subagents it runs the focuses one after another in the main session. The independent review in `implement` (package 13) runs in a subagent; without one, the fallback (for example a fresh session) has to be decided in the ADR. | | Does it have a structured question tool? | The interview skill (package 06) falls back to plain questions. | | Which instruction file does it read (`AGENTS.md`, `.github/copilot-instructions.md`, other)? | Decides which files package 02 writes the managed section into. | @@ -43,7 +43,7 @@ The result is an ADR in `docs/adr/` with a table per agent: supported, supported ### Step 2: install and update -- `integrations install --agent copilot|codex` installs the same content as for Claude Code at user level, as a plugin where the agent has one, otherwise in the agent's user directory. **Decided.** +- `integrations install --agent copilot|codex` installs the same content as for Claude Code at user level, as a plugin where the agent has one, otherwise in the agent's user directory. **Decided** (concept §3.1). - If the research shows a format difference, the engine transforms the content at install time from the one skill source. There is no second copy of the skill text in the kit repository. **Proposed.** - Version recording and `integrations update` work as in package 07. @@ -62,8 +62,8 @@ From this package on, every phase package (09–17) counts as done only when its ## Acceptance criteria - **FP08-AC1** An ADR answers every research question for both agents, with sources. -- **FP08-AC2** After `integrations install --agent copilot`, a Copilot session can load the router skill and run the version handshake. -- **FP08-AC3** After `integrations install --agent codex`, a Codex CLI session can load the router skill and run the version handshake. +- **FP08-AC2** After `integrations install --agent copilot`, a Copilot session can load `shade-kit-next-phase` and run the version handshake. +- **FP08-AC3** After `integrations install --agent codex`, a Codex CLI session can load `shade-kit-next-phase` and run the version handshake. - **FP08-AC4** The kit repository holds one source per skill; agent-specific differences are produced at install time. - **FP08-AC5** The research skill (package 06) produces the same `researches.md` structure on every agent, also where it runs sequentially. - **FP08-AC6** `init` writes the managed section into every instruction file the ADR names. diff --git a/docs/feature-packages/09-discuss-and-intake.md b/docs/feature-packages/09-discuss-and-intake.md index e463c9f..f28c627 100644 --- a/docs/feature-packages/09-discuss-and-intake.md +++ b/docs/feature-packages/09-discuss-and-intake.md @@ -5,7 +5,7 @@ | Status | Draft, 2026-10-14 | | Source | Concept §5.1 (`discuss`, `new`), §5.4, §10 open question 2 | | Depends on | 03, 04, 06 | -| Enables | 10 (`refine`), 16 (`diagnose`), 17 (`quick` implement) | +| Enables | 10 (`refine`), 16 (`diagnose`), 17 (`quick`) | ## Benefit @@ -28,10 +28,10 @@ Ideas can be explored freely before they become a feature, without creating any ### `discuss` -- Brainstorm any topic: an idea, a design question, a problem. Optional, outside every profile, possible at any time. **Decided.** +- Brainstorm any topic: an idea, a design question, a problem. Optional, outside every profile, possible at any time. **Decided** (concept §5.1, §5.4). - Uses the interview skill (package 06) in an open mode: the agent asks, proposes alternatives, and challenges assumptions. - At the end, or when the human asks, the agent writes the notes from the `discussion.md` template: topic, date, participants (git user), options discussed, conclusions, open questions. -- Storage (concept open question 2, the concept's **Proposed** answer, adopted): +- Storage (concept open question 2, **Decided** (confirmation list, 2026-10-18)): - No current feature: `.shadekit/discussions/--.md`. - Current feature: `.shadekit/features//discussions/--.md`. - Discussion notes are committed like all kit data. `discuss` never changes `feature.json`. @@ -39,7 +39,7 @@ Ideas can be explored freely before they become a feature, without creating any ### `new` -Runs when no feature exists for the work yet. The router (package 06) hands off to it. +Runs when no feature exists for the work yet. `shade-kit-next-phase` (package 06) hands off to it, or the human calls it directly. 1. Collect the input: free text, an issue URL, a Markdown file, or discussion notes. 2. Ask for the profile if the human did not name it; propose one from the input (a defect report suggests `bugfix`, a small, clear change suggests `quick`). Default from `config.json`. @@ -47,7 +47,7 @@ Runs when no feature exists for the work yet. The router (package 06) hands off 4. Call `shade-kit feature new --profile

--source <…> [--title <…>] --json`. 5. Fill `request.md`: source kind and reference, the original input unchanged (issue title and body, file content, or notes), date, and who created the feature. The agent does not rewrite or summarise the input. 6. Offer to create and switch to the branch `feature/` (suggested by package 03) so the current feature follows the branch. The agent runs the git command only after a yes. **Proposed.** -7. Report the id and the next phase of the profile (`refine`, `diagnose`, or `implement`). The transition then goes through the normal gate dialogue (package 06). +7. Report the id and the next phase of the profile (`refine`, `diagnose`, or `quick`). The transition then goes through the entry protocol of that phase skill (package 06). ### Templates @@ -63,8 +63,8 @@ Runs when no feature exists for the work yet. The router (package 06) hands off - **FP09-AC3** `new` with an issue URL creates an issue-based id and puts the issue's title and body unchanged into `request.md`. - **FP09-AC4** `new` with a Markdown file or discussion notes copies their content unchanged into `request.md`. - **FP09-AC5** `new` creates a branch only after the human agrees. -- **FP09-AC6** After `new`, the router offers the next phase of the feature's profile through the gate dialogue. +- **FP09-AC6** After `new`, `shade-kit-next-phase` offers the next phase of the feature's profile. ## Open points -- None. Concept open question 2 is answered here with the concept's own proposal; it needs confirmation. +- None. diff --git a/docs/feature-packages/10-refine.md b/docs/feature-packages/10-refine.md index 4842490..b8180d8 100644 --- a/docs/feature-packages/10-refine.md +++ b/docs/feature-packages/10-refine.md @@ -2,7 +2,7 @@ | | | |---|---| -| Status | Draft, 2026-10-14 | +| Status | Draft, 2026-10-14, updated 2026-10-17 (`spec.md` template) | | Source | Concept §3.3 (research focuses), §5.1 (`refine`), §5.2 (DoR), §6 | | Depends on | 06, 09 | | Enables | 11 (`map`), 12 (`plan`, DoR) | @@ -34,26 +34,34 @@ Vague statements, gaps, and contradictions in a requirement are found and resolv - **Gaps and open questions:** vague terms, missing cases, contradictions, unstated assumptions. - **Security at requirement level:** data that needs protection, roles and permissions, abuse cases. 3. Merge the results into `researches.md` (sections `refine / gaps`, `refine / security`). -4. Run the interview skill on the findings: one topic at a time, each with the agent's own proposal. -5. Write or update `spec.md` from the template. +4. Run the interview skill on the findings: one topic at a time, each with the agent's own proposal. Assumptions the agent made are put to the human for confirmation. +5. Write or update `spec.md` from the template. Answered questions move from "Open questions" to "Decisions", and their inline markers are removed. 6. Report what changed and which questions are still open. The human decides whether to run another round or move on. ### `spec.md` +Section order and rules. **Decided** in the template interview on 2026-10-17, except where marked; the general template conventions are in package 02. + | Section | Rule | |---|---| | Goal | One short paragraph: what problem the feature solves and for whom. | -| Requirements | Each with a stable ID `REQ-`, unique within the feature, numbered from `REQ-001`. **Decided** (stable IDs), **Proposed** (format from the concept example). | -| Acceptance criteria | At least one per requirement, written so that a test can decide it. ID `AC-.` linked to its requirement (for example `AC-001.1`). **Proposed.** | -| Out of scope | Stated explicitly, also when empty ("nothing excluded"). | -| Open questions | Every unresolved question. Must be empty before the DoR passes (package 05). | -| Security considerations | The relevant findings from the security research. | - -IDs are never renumbered. A removed requirement keeps its ID with the mark "removed", so references in `tasks.md`, tests, and the report stay valid. **Proposed.** +| Glossary | Domain terms and key entities the requirements use, as a table `Term \| Kind (term or entity) \| Meaning`. An entity names its key attributes and relations, without implementation details. "None." if nothing needs a definition. | +| Requirements | One subsection per requirement, with a stable ID `REQ-`, unique within the feature, numbered from `REQ-001`, and the fields Priority, Status, and Rationale, followed by the requirement statement and its acceptance criteria. | +| Priority | MoSCoW: `must`, `should`, or `could`. A "won't" goes into "Out of scope". | +| Status | `active` or `removed` (with the reason). | +| Acceptance criteria | At least one per requirement, listed under it, with ID `AC-.` (for example `AC-001.1`). Given/When/Then for behaviour; one verifiable statement for non-functional criteria such as performance, security, or compatibility. Every criterion can be decided by an automated test or is marked "(manual)" with the reason. | +| Edge cases | Boundary conditions, error scenarios, and unusual inputs. Each points to the acceptance criterion that covers it or is listed in "Out of scope". | +| Security considerations | The findings from the security research, each linked to the requirement or criterion that handles it, or "None identified." with the reason. | +| Out of scope | Stated explicitly, also when empty ("Nothing excluded."). | +| Assumptions | Defaults the agent chose where the request was silent, with ID `A-` and status `unconfirmed`, `confirmed`, or `risk`. Each is confirmed or marked as a risk before the DoR passes. | +| Open questions | Every unresolved question, with ID `Q-` and the requirements it affects. The unclear statement is marked inline with `(open: Q-)`. Must say "None." before the DoR passes (package 05). | +| Decisions | Answered questions, keeping their `Q-` ID: question → answer, who decided, and the date. | + +IDs are never renumbered. A removed requirement keeps its ID with the status "removed", so references in `tasks.md`, tests, and the report stay valid. **Proposed** in the first draft, adopted in the template. ### Repeated rounds -`refine` can run several times. **Decided.** A new round starts from the current `spec.md`. If the feature has moved on, the human reopens `refine` through the router (package 04 reopen). Later phases then go back to `pending`, and their artifacts must be checked against the changed spec when they run again. +`refine` can run several times. **Decided** (concept §5.1). A new round starts from the current `spec.md`. If the feature has moved on, the human reopens `refine` through `shade-kit-next-phase` (package 04 reopen). Later phases then go back to `pending`, and their artifacts must be checked against the changed spec when they run again. ## Acceptance criteria @@ -62,6 +70,8 @@ IDs are never renumbered. A removed requirement keeps its ID with the mark "remo - **FP10-AC3** A second round keeps every existing ID; a removed requirement is marked "removed", not deleted. - **FP10-AC4** `spec.md` always has an out-of-scope section and an open-questions section. - **FP10-AC5** The interview asks about every gap finding or records why it was dismissed. +- **FP10-AC6** Every requirement in `spec.md` has a priority (`must`, `should`, or `could`) and a status. +- **FP10-AC7** An answered question appears in "Decisions" with its original `Q-` ID, and no `(open: Q-)` marker for it is left in `spec.md`. ## Open points diff --git a/docs/feature-packages/11-map.md b/docs/feature-packages/11-map.md index 4a87b1d..42b0482 100644 --- a/docs/feature-packages/11-map.md +++ b/docs/feature-packages/11-map.md @@ -48,7 +48,7 @@ Before planning, the team knows where the feature lands in the code: which archi The human can skip `map` on purpose, for example for a change confined to one file. The agent then records in `researches.md` a `map / skipped` section with the reason and the git user. The DoR item "affected areas known (`map` done, or skipped on purpose)" (package 05) passes on that record. **Proposed.** -Skipping means skipping the work, not the phase: the engine does not allow skipping a phase (package 04), so the feature still passes through `map` and its gate. This keeps the decision visible in the history. +Skipping means skipping the work, not the phase: the engine does not allow skipping a phase (package 04), so the feature still passes through `map` and its gate. This keeps the decision visible in the history. **Decided** (confirmation list, 2026-10-18). ## Acceptance criteria diff --git a/docs/feature-packages/12-plan.md b/docs/feature-packages/12-plan.md index a293eb8..348e260 100644 --- a/docs/feature-packages/12-plan.md +++ b/docs/feature-packages/12-plan.md @@ -2,7 +2,7 @@ | | | |---|---| -| Status | Draft, 2026-10-14 | +| Status | Draft, 2026-10-14, updated 2026-10-17 (`plan.md` and `tasks.md` templates) | | Source | Concept §5.1 (`plan`), §5.2 (DoR), §6, §4.1 (`plan.md`, `tasks.md`) | | Depends on | 05, 06, 10, 11 | | Enables | 13 (`implement`), 14 (`verify` coverage check), 15 (report) | @@ -30,27 +30,68 @@ Implementation starts only from a spec that passed the Definition of Ready, and 1. Read `spec.md` and `researches.md`. 2. Clarify open design choices with the interview skill (package 06), each with the agent's own recommendation. -3. Write `plan.md`: approach, design decisions with the rejected alternatives, affected components, risks, and the order of work. -4. Write `tasks.md`: the tasks with their requirement IDs and the tests that prove them. +3. Write `plan.md` (see below): how the spec is built and why. +4. Write `tasks.md` (see below): the tasks in the order they are worked on, with their requirement IDs and the tests that prove them, and the coverage matrix. 5. Check the mapping (see below) and report gaps before the human is asked to move on. +The order of work is the task order in `tasks.md`; `plan.md` does not repeat it, so the two cannot drift apart. **Decided** in the template interview on 2026-10-17. + +### `plan.md` + +Section order and rules. **Decided** in the template interview on 2026-10-17; the general template conventions are in package 02. + +| Section | Rule | +|---|---| +| Summary | Three to five sentences: what the feature does and the chosen technical approach. | +| Technical context | Language and runtime, frameworks and libraries, storage, test frameworks, target platforms, constraints. Taken from the code base, not guessed; "n/a" where a line does not apply. | +| Approach | The main idea, the data flow, and the key interfaces, with `REQ-` references. | +| Affected components | Table `Component or path \| Change (new, changed, removed) \| Requirements`. | +| Design decisions | One subsection per decision with a real alternative, ID `D-`, in ADR-light form: Context, Decision, Rejected alternatives (each with the reason), Consequences. | +| Test strategy | Test levels (unit, integration, manual) with scope and location, and the quality commands for build, test, lint, and coverage. The human approves the commands together with the plan; `implement` and `verify` run them (packages 13 and 14). | +| Migration and rollout | Data or format migrations, breaking changes, feature flags, backwards compatibility, rollback; "None." with the reason. | +| Risks | Table `Risk \| Impact \| Mitigation`, including every fact that could not be verified. | +| Deviations | Filled in by `implement` (package 13): date, what changed, and why. "None." while planning. | + ### `tasks.md` -Every task has: +**Decided** in the template interview on 2026-10-17; the general template conventions are in package 02. + +Every task is one checkbox item with fields below it: + +```markdown +- [ ] **T-003** – Add token refresh to the SSO client + - Requirements: REQ-002 + - Depends on: T-001 + - Parallel: no + - Affected: `src/Auth/SsoClient.cs` + - Proving tests: + - [ ] `SsoClientTests.Refresh_ExpiredToken_RenewsToken` → AC-002.1 + - [ ] `SsoClientTests.Refresh_RevokedToken_Throws` → AC-002.2 + - Notes: – +``` | Field | Rule | |---|---| -| ID | `T-`, stable like requirement IDs. **Proposed.** | -| Description | What is built or changed. | +| Checkbox | The task status. `[x]` means done: the code is written and every proving test of the task exists and passes. There is no separate status field. | +| ID | `T-`, stable like requirement IDs. | +| Title | What is built or changed. | | Requirements | One or more `REQ-` IDs from `spec.md`. | -| Proving tests | The tests that prove the task's acceptance criteria: test name or planned test name, and the `AC-` IDs it covers. | -| Status | `open`, `done`, updated by `implement` (package 13). | +| Depends on | `T-` IDs that must be done first, or `none`. | +| Parallel | `yes` only when the task touches other files than every task it could run alongside and depends on none of them. | +| Affected | Paths or components the task touches (from `map` and `plan.md`). | +| Proving tests | One checkbox per test: test name or planned test name → the `AC-` IDs it covers. `[x]` when the test exists and passes. | +| Notes | Deviations and remarks from `implement`. A task that is not done at the end of `implement` keeps its empty checkbox, and its notes start with `Not done: blocked \| skipped \| dropped – `. | + +Order: one flat list in the order the tasks are worked on. A large feature may group its tasks under stage headings (Setup, Foundation, Requirements, Polish), each ending with a "Checkpoint" line that says what works at that point. + +The coverage matrix at the end of `tasks.md` has one row per acceptance criterion of every requirement that is not removed: `Criterion | Requirement | Proving tests | Tasks | Manual`. `plan` writes it, `implement` keeps it in sync with the task blocks, and `verify` (package 14) and the report (package 15) read it. Mapping rules (**Decided** in principle, concept §6): - Every requirement that is not marked "removed" appears in at least one task. -- Every acceptance criterion is covered by at least one planned test. -- A criterion that cannot be tested automatically is marked "manual" and goes into the playbook in `verify` (package 14). **Proposed.** +- Every acceptance criterion is covered by at least one planned test, or is marked manual in the coverage matrix. +- A criterion that cannot be tested automatically is marked "manual" with the reason and goes into the playbook in `verify` (package 14). **Proposed.** +- A matrix row without a proving test that is not marked manual is a gap. The agent checks these rules; the engine does not parse `tasks.md` (package 02). @@ -58,8 +99,11 @@ The agent checks these rules; the engine does not parse `tasks.md` (package 02). - **FP12-AC1** After `plan`, every requirement not marked "removed" appears in at least one task in `tasks.md`. - **FP12-AC2** Every acceptance criterion in `spec.md` is covered by a planned test or marked "manual". -- **FP12-AC3** `plan.md` lists design decisions with the alternatives that were rejected. +- **FP12-AC3** Every design decision in `plan.md` has a `D-` ID, its context, the decision, every rejected alternative with the reason, and its consequences. - **FP12-AC4** The skill reports every mapping gap before the human is asked to approve the transition to `implement`. +- **FP12-AC5** The coverage matrix in `tasks.md` has exactly one row for every acceptance criterion of every requirement that is not removed. +- **FP12-AC6** The test strategy in `plan.md` names a command for build, test, lint, and coverage, or states why one does not apply. +- **FP12-AC7** Every task in `tasks.md` has a checkbox, a `T-` ID, at least one `REQ-` ID, and every field of the task block. ## Open points diff --git a/docs/feature-packages/13-implement.md b/docs/feature-packages/13-implement.md index ba085eb..f1ad9cc 100644 --- a/docs/feature-packages/13-implement.md +++ b/docs/feature-packages/13-implement.md @@ -2,7 +2,7 @@ | | | |---|---| -| Status | Draft, 2026-10-14 | +| Status | Draft, 2026-10-14, updated 2026-10-17 (task checkboxes, commands from `plan.md`) and 2026-10-18 (owner of the review skill) | | Source | Concept §5.1 (`implement`), §11.1 (implement loop), §4.1 (`review.md`), §6 | | Depends on | 06, 12 | | Enables | 14 (`verify`), 15 (DoD item "no open blockers") | @@ -17,14 +17,16 @@ Code is written task by task against the plan, cleaned up, and reviewed independ - The `implement` phase skill in the `feature` profile. - The loop implement → simplify → review. +- The general skill `shade-kit-review`, also used by the `quick` phase (package 17). **Decided** on 2026-10-18 (findings clarification): this package owns the skill and the `review.md` format; package 06 lists it among the general skills. - The `review.md` template and finding severities. - Updating task status in `tasks.md`. ### Out of scope -- The `bugfix` variant (failing test first) and the `quick` variant (all in one). Packages 16 and 17 add them to this skill. +- The `bugfix` variant (failing test first). Package 16 adds it to this skill. +- The `quick` profile. It has its own phase `quick` with its own skill (package 17) instead of an `implement` phase. - The final verification of acceptance criteria and the quality checks for the DoD (package 14). -- Committing. The kit never commits, pushes, or opens pull requests; the human does. **Proposed.** +- Committing. The kit never commits, pushes, or opens pull requests; the human does. **Decided** (confirmation list, 2026-10-18). ## Functional description @@ -36,7 +38,7 @@ implement ──► simplify ──► review ──(blockers)──► implemen └──(no blockers)──► ready for verify ``` -1. **Implement:** work through the open tasks in `tasks.md` in plan order. For each task, write the code and the proving tests named in the task, run build and tests, and set the task to `done` when they pass. Deviations from the plan are recorded in `plan.md` with the reason. +1. **Implement:** work through the open tasks in `tasks.md` in their order, respecting "Depends on". Tasks marked "Parallel: yes" may run in parallel subagents where the agent supports them. **Proposed.** For each task, write the code and the proving tests named in the task, and run build and tests. Check off each proving test when it exists and passes, and the task when all its tests pass (package 12). A task that cannot be finished keeps its empty checkbox and gets a `Not done:` note with the reason. Deviations from the plan are recorded in the "Deviations" section of `plan.md` with the date and the reason; a changed or added test is also updated in the coverage matrix. 2. **Simplify:** go over the changed code and remove duplication, needless complexity, and dead code without changing behaviour. Tests must stay green. 3. **Review:** an independent review of the change against `spec.md`, `plan.md`, and the repository's conventions. Independent means a separate context that did not write the code (a subagent where the agent supports it, see package 08 for the fallback). Findings go into `review.md`. 4. If the review has open blockers, fix them and repeat the loop. @@ -45,7 +47,7 @@ The loop stops after three rounds with open blockers and asks the human how to c ### Quality commands -In v1 the agent runs the build, test, and lint commands itself (concept §6). It takes them from the repository's agent instructions (for example `CLAUDE.md`, `AGENTS.md`) or the build files, and records the commands it used in `review.md`, so `verify` runs the same ones. **Proposed.** +In v1 the agent runs the build, test, and lint commands itself (concept §6). It takes them from the test strategy in `plan.md`, which the human approved with the plan (package 12). If a command does not work as planned, the agent records the change as a deviation in `plan.md`. Without a `plan.md` (`quick` phase, package 17), it takes them from the repository's agent instructions (for example `CLAUDE.md`, `AGENTS.md`) or the build files. In every case it records the commands it used in `review.md`, so `verify` runs the same ones. **Proposed.** ### `review.md` @@ -59,13 +61,15 @@ Severities: `blocker`, `major`, `minor`, `note`. Status: `open`, `fixed`, `accep ## Acceptance criteria -- **FP13-AC1** After `implement`, every task in `tasks.md` is `done` or has a recorded reason why not. +- **FP13-AC1** After `implement`, every task in `tasks.md` is checked off or has a `Not done:` note with the reason. - **FP13-AC2** Every proving test named in `tasks.md` exists after `implement`. - **FP13-AC3** The review runs in a context separate from the one that wrote the code where the agent supports it. - **FP13-AC4** The loop repeats while `review.md` has an `open` blocker and stops after three rounds with a question to the human. - **FP13-AC5** Every finding in `review.md` has an ID, a severity, a location, and a status. - **FP13-AC6** A finding is `accepted` only with a reason and after the human agreed in the conversation. - **FP13-AC7** `review.md` records the build, test, and lint commands used. +- **FP13-AC8** The commands used are the ones from the test strategy in `plan.md`, or every difference is recorded as a deviation in `plan.md`. +- **FP13-AC9** After `implement`, the coverage matrix in `tasks.md` names the same proving tests as the task blocks. ## Open points diff --git a/docs/feature-packages/14-verify.md b/docs/feature-packages/14-verify.md index 656777a..5bb26f9 100644 --- a/docs/feature-packages/14-verify.md +++ b/docs/feature-packages/14-verify.md @@ -2,7 +2,7 @@ | | | |---|---| -| Status | Draft, 2026-10-14 | +| Status | Draft, 2026-10-14, updated 2026-10-17 (commands from `plan.md`, coverage matrix) | | Source | Concept §5.1 (`verify`), §5.2 (DoD), §6 | | Depends on | 06, 12, 13 | | Enables | 15 (DoD gate and report) | @@ -29,8 +29,8 @@ Before a feature is called done, there is evidence that every acceptance criteri ### Run -1. Run the build, test, and lint commands recorded in `review.md` (package 13), plus the coverage command, found the same way as in package 13 and recorded in `verification.md`. **Decided** (agent runs them in v1). -2. For every acceptance criterion in `spec.md`, find the proving tests from `tasks.md` and confirm that they exist and passed in this run. Criteria marked "manual" (package 12) go to the playbook. +1. Run the build, test, and lint commands recorded in `review.md` (package 13), plus the coverage command from the test strategy in `plan.md` (package 12), or found the same way as in package 13 where there is no `plan.md`. Record every command in `verification.md`. **Decided** (agent runs them in v1, concept §6). +2. For every acceptance criterion in `spec.md`, find the proving tests in the coverage matrix of `tasks.md` and confirm that they exist and passed in this run. Criteria marked manual in the matrix (package 12) go to the playbook. 3. Write `verification.md`: date, commands with their results, coverage value, and a table acceptance criterion → test → result. 4. Write `playbook.md`: one checkbox item per manual criterion and per behaviour the human should see working (steps, expected result). Every item starts unchecked. 5. Report the result. If anything failed, recommend reopening `implement` and name the failures. diff --git a/docs/feature-packages/15-finish-and-report.md b/docs/feature-packages/15-finish-and-report.md index 7b96ee6..a294a4c 100644 --- a/docs/feature-packages/15-finish-and-report.md +++ b/docs/feature-packages/15-finish-and-report.md @@ -2,21 +2,21 @@ | | | |---|---| -| Status | Draft, 2026-10-14 | +| Status | Draft, 2026-10-14, updated 2026-10-18 (report is optional) | | Source | Concept §5.1 (`finish`), §5.2 (DoD), §6 | | Depends on | 03, 04, 05, 06, 14 | | Enables | End-to-end `feature` profile; 16 and 17 reuse it | ## Benefit -A finished feature leaves a single report that shows what was required, which tests prove it, what the checks said, and who approved each step. Reviewers of the pull request, and anyone looking at the feature later, get the evidence in one place instead of collecting it from six artifacts. +A finished feature is closed with a recorded state, and on request leaves a single report that shows what was required, which tests prove it, what the checks said, and who approved each step. Reviewers of the pull request, and anyone looking at the feature later, then get the evidence in one place instead of collecting it from six artifacts. Teams that do not need the report switch it off. ## Scope ### In scope - The `finish` phase skill for every profile. -- The `report.md` template. +- The `report.md` template and the optional report (`report` in `config.json`, package 02). - Closing the feature with `phase complete` (package 04). ### Out of scope @@ -29,11 +29,9 @@ A finished feature leaves a single report that shows what was required, which te ### Run -1. Read the feature state with `shade-kit feature show --json` (package 03): profile, phases, approval history. -2. Read the artifacts the profile produced: `spec.md`, `tasks.md`, `review.md`, `verification.md`, `playbook.md`, the filled `checks/dod.md`, and in `bugfix` `diagnosis.md`. -3. Write `report.md` with the report skill (package 06). -4. Show the report summary to the human. -5. Call `shade-kit phase complete`. The feature is `done`. +1. Decide whether a report is written. The `phase start finish` result contains the `report` setting from `config.json`: `always` writes it, `never` skips it, `ask` (default) asks the human. **Decided** on 2026-10-18 (findings clarification): the report is optional. +2. If a report is written: the report skill (package 06) reads the feature state with `shade-kit feature show --json` (package 03: profile, phases, approval history) and the artifacts the profile produced (`spec.md`, `tasks.md`, `review.md`, `verification.md`, `playbook.md`, the filled `checks/definition-of-done.md`, and in `bugfix` `diagnosis.md`), writes `report.md`, and shows its summary to the human. +3. Call `shade-kit phase complete`. The feature is `done`, with or without a report. ### `report.md` @@ -52,15 +50,17 @@ A section whose artifact the profile does not produce says so in one line instea ### After finish - The feature folder stays in the repository with status `done`. Nothing is deleted or archived. **Proposed.** -- The branch may still be checked out. The router (package 06) then reports that the feature is finished. +- The branch may still be checked out. `shade-kit-next-phase` (package 06) then reports that the feature is finished. ## Acceptance criteria -- **FP15-AC1** `report.md` contains every section of the template for every profile. -- **FP15-AC2** The approvals section lists every transition in `history`, including "approval off" transitions. -- **FP15-AC3** Every requirement of `spec.md` that is not marked "removed" appears in the requirements table. -- **FP15-AC4** After `finish`, `feature current` on the feature's branch shows status `done`, and the router reports the feature as finished. +- **FP15-AC1** If a report is written, `report.md` contains every section of the template for every profile. +- **FP15-AC2** In a written report, the approvals section lists every transition in `history`, including "approval off" transitions. +- **FP15-AC3** In a written report, every requirement of `spec.md` that is not marked "removed" appears in the requirements table. +- **FP15-AC4** After `finish`, `feature current` on the feature's branch shows status `done`, and `shade-kit-next-phase` reports the feature as finished. - **FP15-AC5** After `finish`, a local override that pointed to the feature is cleared. +- **FP15-AC6** With `report` `never`, `finish` writes no `report.md` and still completes the feature; with `ask`, it asks the human; with `always`, it writes the report without asking. +- **FP15-AC7** The `phase start finish` result contains the `report` setting, `ask` when `config.json` has none. ## Open points diff --git a/docs/feature-packages/16-bugfix-profile.md b/docs/feature-packages/16-bugfix-profile.md index 66d8635..f011cc2 100644 --- a/docs/feature-packages/16-bugfix-profile.md +++ b/docs/feature-packages/16-bugfix-profile.md @@ -2,7 +2,7 @@ | | | |---|---| -| Status | Draft, 2026-10-14 | +| Status | Draft, 2026-10-14, updated 2026-10-18 (short plan sections, DOR-09 and DOR-10 in `bugfix`) | | Source | Concept §2 (Workflow profiles), §5.4, §11.2 | | Depends on | 04, 05, 06, 09, 12, 13, 14, 15 | | Enables | v1 profile `bugfix` end to end | @@ -16,6 +16,7 @@ A defect is fixed through a short, disciplined path: reproduce it, prove the roo ### In scope - The `diagnose` phase skill and the `diagnosis.md` template. +- The security research of `diagnose` (section `diagnose / security` in `researches.md`). - The `bugfix` variant of `plan` (short plan). - The `bugfix` variant of `implement` (failing test first). - Acceptance of the whole `bugfix` profile from `new` to `finish`. @@ -27,23 +28,24 @@ A defect is fixed through a short, disciplined path: reproduce it, prove the roo ## Functional description -Profile: `new` → `diagnose` → `plan` (short) → `implement` (failing test first) → `verify` → `finish`. **Decided.** +Profile: `new` → `diagnose` → `plan` (short) → `implement` (failing test first) → `verify` → `finish`. **Decided** (concept §5.4). ### `diagnose` 1. Read `request.md`. 2. **Reproduce:** find a reliable way to trigger the bug, record steps, environment, and observed versus expected behaviour. If it cannot be reproduced, stop and ask the human for more information instead of guessing. 3. **Root cause:** trace from the symptom to the cause in the code, with file and line evidence. Name the causes that were checked and ruled out. -4. Write `diagnosis.md`: reproduction, observed versus expected behaviour, root cause with evidence, ruled-out causes, affected code, fix options with a recommendation. -5. Write `spec.md` in the format of package 10: the expected behaviour as requirements with `REQ-` IDs, and at least one acceptance criterion per requirement stating that the reproduction no longer shows the bug. **Proposed**, so the DoR, `plan`, `verify`, and the report work as in the `feature` profile. +4. **Security:** run the research skill (package 06) with the focus "security at code level" (the `map` agent, package 11) on the affected code and the fix options: is the bug itself a vulnerability, and does a fix touch input handling, authentication, secrets, or protected data? The result goes into the section `diagnose / security` of `researches.md`. **Decided** on 2026-10-18 (findings clarification). +5. Write `diagnosis.md`: reproduction, observed versus expected behaviour, root cause with evidence, ruled-out causes, affected code, fix options with a recommendation. +6. Write `spec.md` in the format of package 10: the expected behaviour as requirements with `REQ-` IDs, and at least one acceptance criterion per requirement stating that the reproduction no longer shows the bug. "Security considerations" is filled from `diagnose / security`. **Proposed**, so the DoR, `plan`, `verify`, and the report work as in the `feature` profile. -Because `bugfix` has no `refine` and no `map`, `diagnose` uses the interview skill (package 06) for missing facts, and the DoR item "affected areas known" passes on the "affected code" section of `diagnosis.md`. The `map` reference in that item is not applicable (package 05 rule). +Because `bugfix` has no `refine` and no `map`, `diagnose` uses the interview skill (package 06) for missing facts. The DoR item DOR-10 "affected areas known" passes with the "affected code" section of `diagnosis.md` as evidence, and DOR-09 "security considerations" passes on the section filled from `diagnose / security` (package 05). **Decided** on 2026-10-18 (findings clarification). ### `plan` variant -- Input: `spec.md` and `diagnosis.md` instead of `researches.md`. -- Short `plan.md`: the chosen fix option and why, risks. Usually one to a few tasks. -- The first task in `tasks.md` is always the regression test that reproduces the bug and fails before the fix. **Decided** (failing test first). +- Input: `spec.md`, `diagnosis.md`, and the `diagnose / security` section of `researches.md`. +- Short `plan.md` with four filled sections: Summary (the chosen fix option and why, with a link to `diagnosis.md`), Test strategy (test levels and the quality commands for build, test, lint, and coverage, as in package 12), Risks, and Deviations ("None." while planning; `implement` fills it as in package 13). Every other section says "n/a in bugfix". **Decided** on 2026-10-18 (findings clarification). Usually one to a few tasks. +- The first task in `tasks.md` is always the regression test that reproduces the bug and fails before the fix. **Decided** (failing test first, concept §5.4). ### `implement` variant @@ -54,10 +56,12 @@ Because `bugfix` has no `refine` and no `map`, `diagnose` uses the interview ski - **FP16-AC1** `diagnose` writes `diagnosis.md` with reproduction, root cause with file evidence, and ruled-out causes. - **FP16-AC2** `diagnose` stops and asks the human when the bug cannot be reproduced. -- **FP16-AC3** `diagnose` writes a `spec.md` that passes the DoR check, with the `map` item marked not applicable. +- **FP16-AC3** `diagnose` writes a `spec.md` that passes the DoR check, with DOR-10 passed on the "affected code" section of `diagnosis.md` and DOR-09 passed on the `diagnose / security` research. +- **FP16-AC7** After `diagnose`, `researches.md` has a `diagnose / security` section. +- **FP16-AC8** The short `plan.md` in `bugfix` has a filled Summary, Test strategy with a command for build, test, lint, and coverage (or the reason one does not apply), Risks, and Deviations; every other section says "n/a in bugfix". - **FP16-AC4** In `bugfix`, the first task in `tasks.md` is a failing regression test. - **FP16-AC5** `review.md` records a failing run of the regression test before the first production code change. -- **FP16-AC6** A `bugfix` feature goes from `new` to `done` with `verify` and `finish` unchanged, and its report lists the regression test under requirements and tests. +- **FP16-AC6** A `bugfix` feature goes from `new` to `done` with `verify` and `finish` unchanged, and, if a report is written (package 15), it lists the regression test under requirements and tests. ## Open points diff --git a/docs/feature-packages/17-quick-profile.md b/docs/feature-packages/17-quick-profile.md index 3cd7576..fe2ca90 100644 --- a/docs/feature-packages/17-quick-profile.md +++ b/docs/feature-packages/17-quick-profile.md @@ -1,8 +1,8 @@ -# 17 · Quick profile (all-in-one `implement`) +# 17 · Quick profile (`quick` phase) | | | |---|---| -| Status | Draft, 2026-10-14 | +| Status | Draft, 2026-10-14, updated 2026-10-18 (`quick` is a phase of its own; DoD items, coverage, playbook on demand) | | Source | Concept §2 (Workflow profiles), §5.4, §11.2 | | Depends on | 04, 05, 06, 09, 13, 15 | | Enables | v1 profile `quick` end to end | @@ -15,7 +15,7 @@ Small, clear changes get the kit's traceability and DoD without the ceremony of ### In scope -- The `quick` variant of `implement`: short clarification, compact spec, implementation, review. +- The `quick` phase skill (`shade-kit-quick`): short clarification, compact spec, implementation, review. - Acceptance of the whole `quick` profile from `new` to `finish`. ### Out of scope @@ -26,28 +26,32 @@ Small, clear changes get the kit's traceability and DoD without the ceremony of ## Functional description -Profile: `new` → `implement` (all in one) → `finish`, one approval at the end. **Decided.** +Profile: `new` → `quick` (all in one) → `finish`, one approval at the end. **Decided** (concept §5.4). -### `implement` in `quick` +`quick` is a phase of its own, not a variant of `implement` (**Decided** on 2026-10-18). It reuses the review loop of package 13 and the `verification.md` format of package 14, but has its own skill, so `shade-kit-implement` stays free of `quick` branches. + +### The `quick` phase 1. Read `request.md`. 2. **Short clarification:** ask only the questions that block the change, using the interview skill (package 06). If the change turns out not to be small or not clear, recommend dropping it and creating a `feature` or `bugfix` instead, and stop until the human decides. **Proposed.** 3. **Compact spec:** write `spec.md` in the format of package 10 with only goal, requirements with `REQ-` IDs, and acceptance criteria; the other sections state "not applicable in quick". **Proposed**, so the DoD and the report can trace requirements to tests also in `quick`. 4. **Implement and test:** write the code and the tests that prove each acceptance criterion. No `plan.md` or `tasks.md`. 5. **Simplify and review:** the loop of package 13 with `review.md`, so the DoD item "no open blockers" applies. -6. **Self-check:** run the quality commands and record the criterion → test mapping and the results in `verification.md` (format of package 14), so the DoD can be checked without a `verify` phase. **Proposed.** +6. **Self-check:** run the quality commands (build, test, lint, and coverage) and record the criterion → test mapping, the results, and the coverage value in `verification.md` (format of package 14), so the DoD can be checked without a `verify` phase. **Proposed.** +7. **Playbook on demand:** if an acceptance criterion is marked "(manual)", write `playbook.md` in the format of package 14 with one unchecked item per manual criterion. Without manual criteria, no `playbook.md` is written. **Decided** on 2026-10-18 (findings clarification). ### DoD in `quick` -The gate into `finish` uses the normal DoD (package 05). Items without an artifact in `quick`, for example `playbook.md`, are not applicable. +The gate into `finish` uses the normal DoD (package 05). DOD-04 (`tasks.md`) and DOD-07 (`plan.md`) are not applicable. DOD-09 (`playbook.md`) is not applicable when no criterion is manual and therefore no `playbook.md` exists; otherwise it applies as in every profile. **Decided** on 2026-10-18 (findings clarification). ## Acceptance criteria - **FP17-AC1** A `quick` feature goes from `new` to `done` with exactly one human approval, the one into `finish`. -- **FP17-AC2** `implement` in `quick` writes `spec.md` with `REQ-` IDs and acceptance criteria, `review.md`, and `verification.md`, and no `plan.md` or `tasks.md`. -- **FP17-AC3** The DoD check in `quick` marks the playbook item as not applicable and checks every other item. +- **FP17-AC2** The `quick` phase writes `spec.md` with `REQ-` IDs and acceptance criteria, `review.md`, and `verification.md` with the coverage value, `playbook.md` only when a criterion is manual, and no `plan.md` or `tasks.md`. +- **FP17-AC3** The DoD check in `quick` marks DOD-04 and DOD-07 as not applicable, marks DOD-09 as not applicable only when no `playbook.md` exists, and checks every other item. - **FP17-AC4** When the change turns out to be large or unclear, the skill stops and recommends another profile. -- **FP17-AC5** The `quick` report has the same sections as every other report (package 15). +- **FP17-AC5** If a report is written (package 15), the `quick` report has the same sections as every other report. +- **FP17-AC6** `phase skills` returns `shade-kit-quick` for the `quick` phase, and `shade-kit-implement` contains no `quick` branch. ## Open points diff --git a/docs/feature-packages/18-upgrade.md b/docs/feature-packages/18-upgrade.md index 7f253f5..eb36174 100644 --- a/docs/feature-packages/18-upgrade.md +++ b/docs/feature-packages/18-upgrade.md @@ -3,7 +3,7 @@ | | | |---|---| | Status | Draft, 2026-10-14 | -| Source | Concept §2 (Lifecycle), §3.1 (`upgrade`), §9; [ADR 0001](../adr/0001-shadekit-version-drift.md) decisions 2, 3, 5 | +| Source | Concept §2 (Lifecycle), §3.1 (`upgrade`), [§9](../../research/shade-kit-concept.md#9-version-drift) (§9.2, §9.3, §9.5) | | Depends on | 01, 02, 05, 08 | | Enables | Kit releases that change a file format or a template, without stranding existing repositories | @@ -24,7 +24,7 @@ When the kit evolves, a team brings its repository to the new formats with one s ### Out of scope - Updating the engine itself or the agent integrations (`integrations update`, package 07). -- Version pinning of the engine per repository (ADR 0001, possible later addition). +- Version pinning of the engine per repository (concept §9, possible later addition). - Merging a `.new` template into the team's version. The human does that. ## Functional description @@ -32,19 +32,19 @@ When the kit evolves, a team brings its repository to the new formats with one s ### Preconditions - The repository is initialised (package 01 kit root check). -- `.shadekit/` has no uncommitted changes. Otherwise `upgrade` stops, because git is the backup. **Decided.** +- `.shadekit/` has no uncommitted changes. Otherwise `upgrade` stops, because git is the backup. **Decided** (concept §9.3). - No file has a format newer than the engine supports; otherwise the hard stop from package 01 applies. ### JSON files -- Every JSON file in `.shadekit/` (`config.json`, `gates.json`, `templates.lock.json`, every `feature.json`) is migrated from its own `schemaVersion` to the engine's current format through its chain. Files from different format versions after a merge are handled file by file. **Decided.** -- Features in any phase are migrated in place. **Decided.** +- Every JSON file in `.shadekit/` (`config.json`, `gates.json`, `templates.lock.json`, every `feature.json`) is migrated from its own `schemaVersion` to the engine's current format through its chain. Files from different format versions after a merge are handled file by file. **Decided** (concept §9.1, §9 Consequences). +- Features in any phase are migrated in place. **Decided** (concept §9.3). - Unknown fields are kept (package 01). - Gitignored local files (`current-feature.json`) are migrated too, but are not part of the uncommitted-changes check. **Proposed.** ### Templates -For each template the running kit version ships (**Decided**, ADR 0001): +For each template the running kit version ships (**Decided**, concept §9.5): | State in the repository | Action | |---|---| @@ -66,7 +66,7 @@ The managed section in `CLAUDE.md`, `AGENTS.md`, and any further instruction fil ### All or nothing -Every changed file is first written to a temporary file; only when all are written are they renamed into place. If anything fails, no file in `.shadekit/` or in the instruction files has changed. **Decided** (for `.shadekit/`), **Proposed** (extended to the instruction files). +Every changed file is first written to a temporary file; only when all are written are they renamed into place. If anything fails, no file in `.shadekit/` or in the instruction files has changed. **Decided** (for `.shadekit/`, concept §9.3), **Proposed** (extended to the instruction files). ### Output @@ -81,7 +81,7 @@ A summary per file: migrated from version x to y, template replaced, `.new` writ - **FP18-AC5** If one file fails to migrate, no file is changed. - **FP18-AC6** `--dry-run` writes nothing and reports the same actions as a real run. - **FP18-AC7** The managed section is replaced, and content outside the markers is byte-for-byte unchanged. -- **FP18-AC8** Every migration step that exists has a test that migrates a fixture from the previous version (ADR 0001 consequence). +- **FP18-AC8** Every migration step that exists has a test that migrates a fixture from the previous version (concept §9 consequence). ## Open points diff --git a/docs/feature-packages/19-release-and-distribution.md b/docs/feature-packages/19-release-and-distribution.md index 3b42b35..277bae2 100644 --- a/docs/feature-packages/19-release-and-distribution.md +++ b/docs/feature-packages/19-release-and-distribution.md @@ -2,27 +2,39 @@ | | | |---|---| -| Status | Draft, 2026-10-14 | -| Source | Concept §3.4; `CLAUDE.md` (Engine deliverables, CI); [ADR 0001](../adr/0001-shadekit-version-drift.md) decision 6 | +| Status | Draft, 2026-10-14, updated 2026-10-18 (reduced to the remaining scope; CI and release workflow are implemented; release notes and branch protection checked) | +| Source | Concept §3.4; `CLAUDE.md` (Engine deliverables, CI), [concept §9.6](../../research/shade-kit-concept.md#9-version-drift) | | Depends on | 07, 08 (content packed into the release); technically every package that is part of a release | | Enables | Teams outside the kit repository can install and use SHADE kit | +Labels follow the concept: **Decided**, **Proposed**, **Open**, **Unverified**, **Implemented**. + ## Benefit A developer gets SHADE kit on any supported machine in one step, with or without a .NET installation, and every release delivers engine and agent content with the same version number, so the version handshake works out of the box. +## Implemented + +The build and release pipeline exists. `CLAUDE.md` (sections ".NET conventions" and "Workflow") is the description of record; this package does not repeat it. + +- One version for every project, the tool package, and the binaries, derived by GitVersion from the git history. **Implemented** (`GitVersion.yml`). +- The `dotnet` global tool package (command `shade-kit`) and self-contained single-file binaries for `osx-arm64`, `linux-x64`, `linux-arm64`, `win-x64`, `win-arm64`. **Implemented** (Cake build in `build/`). +- Pull requests to `main` run pack (build, test, coverage) and the smoke test (`shade-kit version --json` on the native binary) on Ubuntu, Windows, and macOS. **Implemented** (`.github/workflows/pull-request.yml`). +- A tag `v` on `main` builds, tests, and smoke-tests on all three platforms, pushes the tool package to nuget.org, and creates a GitHub release with the five binary archives and the tool package. **Implemented** (`.github/workflows/release.yml`). + ## Scope ### In scope -- One version number per release for engine and integration content. -- The `dotnet` global tool package (command `shade-kit`). -- Self-contained single-file binaries for `osx-arm64`, `linux-x64`, `linux-arm64`, `win-x64`, `win-arm64`. +- Packing the integration content (skills, research agent definitions) into the engine release, so engine and content share one version. +- The version rule for format changes. - An installer script for the binaries. -- CI: build and test on every pull request, and a release workflow that produces all artifacts. +- Release notes that list format changes and the need for `upgrade`. +- Making a failing pull request build block the merge into `main`. ### Out of scope +- Everything listed under "Implemented". - Publishing the plugin to an agent's public marketplace. In v1 the content reaches the agent through `integrations install` (packages 07 and 08). **Proposed.** - Automatic self-update of the engine. @@ -30,32 +42,25 @@ A developer gets SHADE kit on any supported machine in one step, with or without ### Version -- Engine and integration content are released together under one version number. **Decided** (ADR 0001). Because the content is packed into the engine (package 07), one build produces both. +- Engine and integration content are released together under one version number. **Decided** (concept §9.6). Because the content is packed into the engine (package 07), one build produces both. - The version follows semantic versioning; a format version change (package 01) is at least a minor release, so the handshake rule (major and minor equal, package 06) catches it. **Proposed.** -### Artifacts per release +### Additional release artifacts | Artifact | For whom | |---|---| -| `dotnet` tool package | Developers with the .NET SDK: `dotnet tool install -g`. **Decided.** | -| Single-file binaries, five platforms | Developers without .NET. **Decided.** | | Installer script (shell for macOS and Linux, PowerShell for Windows) | Downloads the binary for the platform, puts it on the `PATH`, and prints the next step (`shade-kit integrations install`). **Proposed** in the concept. | -| Release notes | Every format version change and its `upgrade` need are listed. **Proposed.** | - -### CI - -- Every pull request runs build and test. **Decided** (`CLAUDE.md`, planned). This part does not depend on any feature and can be set up at any time, ideally before package 01. -- A release workflow, triggered by a version tag, builds and tests once, then produces the tool package, the five binaries, and the release notes, and attaches them to a GitHub release. **Decided** (`CLAUDE.md`). -- The release workflow runs a smoke test on each binary it can run on a hosted runner (`shade-kit version --json`). **Proposed.** +| Release notes | Every format version change and its `upgrade` need are listed. **Proposed.** Today the `CreateGitHubRelease` task of `CreativeCoders.CakeBuild` 6.10.0 uses `ReleaseBody` from the build context, and `build/BuildContext.cs` sets it to the fixed text `SHADE kit Release` (checked 2026-10-18). The release notes therefore have to be generated first, for example from the Conventional Commits or a changelog, and passed as `ReleaseBody`. | ## Acceptance criteria -- **FP19-AC1** A pull request runs build and test, and a failure blocks the merge. -- **FP19-AC2** A version tag produces the tool package and five binaries with that version. +- **FP19-AC1** A pull request runs build and test, and a failure blocks the merge. The workflow is **Implemented**. The merge block is not met (checked 2026-10-18 with the GitHub API): `main` has no classic branch protection; the repository ruleset 25112099 requires a pull request (squash only) and forbids deletion and force pushes, and it has a `required_status_checks` rule whose list of checks is empty. Needed step: add the checks of `.github/workflows/pull-request.yml` (`ubuntu-latest`, `windows-latest`, `macos-latest`) as required checks to the ruleset. The ruleset lives in the GitHub settings, not in the repository. +- **FP19-AC2** A version tag produces the tool package and five binaries with that version. **Implemented.** - **FP19-AC3** `shade-kit version --json` of every artifact reports the same engine version, and `integrations install` from it installs content with that version. - **FP19-AC4** The installer script installs the binary on macOS, Linux, and Windows and prints the next step. - **FP19-AC5** On a machine without .NET, the binary runs `init` and `integrations install`. +- **FP19-AC6** The release notes of a release that raises a format version name the file type, the old and new version, and that `shade-kit upgrade` is needed. Not met today: the release body is the fixed text `SHADE kit Release`. ## Open points -- None. +- None of its own. diff --git a/docs/feature-packages/README.md b/docs/feature-packages/README.md index 29f5998..4e90336 100644 --- a/docs/feature-packages/README.md +++ b/docs/feature-packages/README.md @@ -1,10 +1,10 @@ # SHADE kit v1 feature packages -Status: draft, 2026-10-14. Derived step by step from [`research/shade-kit-concept.md`](../../research/shade-kit-concept.md) and [ADR 0001](../adr/0001-shadekit-version-drift.md). Existing code was not taken into account. +Status: draft, 2026-10-14, updated 2026-10-18 (consistency review, findings clarification, and implementation of package 02). Derived step by step from [`research/shade-kit-concept.md`](../../research/shade-kit-concept.md); its [section 9](../../research/shade-kit-concept.md#9-version-drift) holds the version-drift decisions of the former ADR 0001. Parts that exist in the code are labelled **Implemented**. Each package describes the functional behaviour of one clearly bounded piece of v1, with its benefit, scope, acceptance criteria (`FPnn-ACm`), and open points. The number prefix is the implementation order: every package depends only on packages with a lower number. -Labels follow the concept: **Decided** (confirmed in the concept interview), **Proposed** (added in the concept or in these packages, not confirmed yet), **Open**, **Unverified**. +Labels follow the concept: **Decided** (confirmed in the concept interview or a later interview or review named with its date), **Proposed** (added in the concept or in these packages, not confirmed yet), **Open**, **Unverified**, **Implemented** (exists in the code; the label names where). ## Packages @@ -15,16 +15,16 @@ Labels follow the concept: **Decided** (confirmed in the concept interview), **P | 03 | [Feature management](03-feature-management.md) | Collision-free feature ids; the engine knows the current feature | 01, 02 | | 04 | [Phase engine and workflow profiles](04-phase-engine-and-workflow-profiles.md) | The engine referees phases and records every approval | 01, 03 | | 05 | [Gates and checklists](05-gates-and-checklists.md) | Configurable approvals, DoR and DoD with a record per feature | 02, 04 | -| 06 | [Skill foundation](06-skill-foundation.md) | "Continue" finds the next step; shared interview, gate, research, report skills | 01, 03, 04, 05 | +| 06 | [Skill foundation](06-skill-foundation.md) | "Continue" finds the next step; shared interview, gate, research, and optional report skills | 01, 03, 04, 05 | | 07 | [Claude Code integration](07-claude-code-integration.md) | One-command, version-matched install for Claude Code | 01, 06 | | 08 | [GitHub Copilot and OpenAI Codex CLI integration](08-copilot-and-codex-integration.md) | Same workflow on all three agents | 02, 06, 07 | | 09 | [Discuss and intake](09-discuss-and-intake.md) | Free brainstorming; any input becomes a feature with the original kept | 03, 04, 06 | | 10 | [Refine](10-refine.md) | A spec with stable requirement IDs and testable criteria | 06, 09 | | 11 | [Map](11-map.md) | Plans build on facts about the code base | 06, 10 | | 12 | [Plan](12-plan.md) | Every requirement bound to tasks and proving tests | 05, 06, 10, 11 | -| 13 | [Implement](13-implement.md) | Code passes an independent review loop before verification | 06, 12 | +| 13 | [Implement](13-implement.md) | Code passes an independent review loop (review skill) before verification | 06, 12 | | 14 | [Verify](14-verify.md) | Evidence for every criterion, plus a manual playbook | 06, 12, 13 | -| 15 | [Finish and report](15-finish-and-report.md) | One report with requirements, tests, checks, approvals | 03, 04, 05, 06, 14 | +| 15 | [Finish and report](15-finish-and-report.md) | Feature closed with a record; optional report with requirements, tests, checks, approvals | 03, 04, 05, 06, 14 | | 16 | [Bugfix profile](16-bugfix-profile.md) | Reproduce, root cause, failing test first | 04, 05, 06, 09, 12–15 | | 17 | [Quick profile](17-quick-profile.md) | Traceability for small changes with one approval | 04, 05, 06, 09, 13, 15 | | 18 | [Repository upgrade](18-upgrade.md) | Safe, all-or-nothing migration to new kit versions | 01, 02, 05, 08 | @@ -35,7 +35,7 @@ Labels follow the concept: **Decided** (confirmed in the concept interview), **P | Milestone | Packages | Result | |---|---|---| | M1 Engine core | 01–05 | The CLI manages repositories, features, phases, and gates; usable by hand. | -| M2 Agent foundation | 06–08 | Router and shared skills run on Claude Code, Copilot, and Codex. | +| M2 Agent foundation | 06–08 | `shade-kit-next-phase`, the entry protocol, and the shared skills run on Claude Code, Copilot, and Codex. | | M3 `feature` profile end to end | 09–15 | First v1 success criterion reachable: `new` to `finish` on every agent. | | M4 All v1 profiles | 16–17 | `bugfix` and `quick` complete. | | M5 Releasable | 18–19 | Upgrades and distribution; v1 can be released. | @@ -75,37 +75,28 @@ Not in any package because the concept puts them later: MCP server, extensions ( | `feature.json` | Engine (03, 04) | | `request.md` | 09 | | `discussions/*.md` | 09 | -| `researches.md` | 10, 11 (format 06) | +| `researches.md` | 10, 11, 16 (format 06) | | `spec.md` | 10; in `bugfix` 16, in `quick` 17 | | `diagnosis.md` | 16 | | `plan.md`, `tasks.md` | 12 (bugfix variant 16) | | `review.md` | 13 (also 16, 17) | | `verification.md` | 14 (also 17) | -| `playbook.md` | 14 | -| `checks/dor.md`, `checks/dod.md` | Engine copies (05), agent fills (06) | -| `report.md` | 15 | +| `playbook.md` | 14 (also 17, only for manual criteria) | +| `checks/definition-of-ready.md`, `checks/definition-of-done.md` | Engine copies (05), agent fills (06) | +| `report.md` | 15 (optional) | ## Changes and additions to the concept that need confirmation -1. `gates.json` is keyed by profile, then by target phase (05). The concept example is keyed by phase only, which cannot express `quick`'s "one approval at the end". -2. New artifacts not in the concept tree: `discussions/` (09), `diagnosis.md` (16), `verification.md` (14), `report.md` (15). -3. New commands: `feature show` (03), `phase complete` (04); `feature drop` needs `--yes` (03). Resume and reopen are expressed through `phase start` (04). -4. Approval off together with a checklist: the checklist is checked but only warns (05, 06). -5. "Skipping `map`" skips the work, not the phase (11). -6. Hooks and MCP configuration are not shipped in v1 because no v1 feature needs them (07). -7. The integration content is packed into the engine release (07); the kit never commits (13). -8. `data.md` from the concept tree is not used by any package (concept open question 1); v1 does not create it unless its purpose is decided. -9. `CLAUDE.md` still lists the old v1 phases (concept §2 note) and should be updated once the concept and these packages are accepted. -10. Concept §11.4 says "stay in refine" after a rejected gate into `plan`; with the `feature` profile the phase before `plan` is `map`. In these packages, a rejection resumes whatever phase is current (04, 06). +None. The earlier list (keyed `gates.json`, new artifacts and commands, approval off with a checklist, skipping `map`, no hooks and MCP in v1, integration content in the engine release, `data.md` not created, `CLAUDE.md` update, obsolete "stay in refine") was confirmed on 2026-10-18 and taken over into the concept and `CLAUDE.md`. ## Open questions | Concept question | Where | State | |---|---|---| -| 1 Purpose of `data.md` | README, change 8 | Open, not needed for v1 | -| 2 Where `discuss` output is stored | 09 | Concept proposal adopted, needs confirmation | +| 1 Purpose of `data.md` | Concept §4.1 | Open, not needed for v1; v1 does not create it | +| 2 Where `discuss` output is stored | 09 | Decided on 2026-10-18 | | 3 Branch in separate spec store mode | — | Later, not v1 | -| 4 Approval off completely or only checklist | 05 | Both switchable, concept proposal adopted | +| 4 Approval off completely or only checklist | 05 | Decided on 2026-10-18: both switchable | | 5 Copilot and Codex format compatibility | 08 step 1 | Unverified, research is the first step of 08 | | 6 Exact CLI command names | all | Concept proposals used throughout | | 7 Extension ordering | — | Later, not v1 | @@ -117,10 +108,11 @@ New open points from the packages: - Changing the profile of an existing feature (04). - Coverage threshold (05, 14; proposal `coverageThreshold` in `config.json`). - Supported way to install a Claude Code plugin at user level from a CLI (07, **Unverified**). +- Follow-ups from the implementation of 02: plain output wrapped at 80 columns when stdout is not a terminal, comma-separated values accepted by the shared enum converter (01), skeleton templates that name their owner package, a second `.gitignore` comment line when only an entry is missing (02). From its code review: a dangling symbolic link ends with a host error, a read-only target file fails only after `.shadekit/` is written, and the messages for exit codes 14, 23, and 24 name `shade-kit init` although package 18 reuses the writers (02). ## Consistency log -After each package, the whole draft was checked against the concept, ADR 0001, and the packages before it. +After each package, the whole draft was checked against the concept, ADR 0001 (now concept section 9), and the packages before it. | After | Finding | Resolution | |---|---|---| @@ -142,3 +134,7 @@ After each package, the whole draft was checked against the concept, ADR 0001, a | 18 | `current-feature.json` and the user-level integration record were missing from 01's file types; nobody migrated the integration record. | Added to 01; 07 migrates it. | | 19 | Format changes must be visible to the handshake. | Format change is at least a minor release. | | All | Automated check: every dependency points to a lower number, every package reference exists, every AC id is unique. `gates.json` and templates in 02 depend on later content. | Check passed. 02 clarified: skeletons and a version-only `gates.json` until the owner package is built. | +| Templates (2026-10-17) | First drafts of the `spec`, `plan`, `tasks`, DoR, and DoD templates (template interview) differed from the packages: `*.template.md` source names, no order of work in `plan.md`, task status as checkbox, coverage matrix, more spec sections, ten DoR and nine DoD items in a result table, quality commands planned in `plan.md`. | 02 (source names, template conventions), 05 (checklist format, default items), 06 (conventions, checklist check), 10 (`spec.md`), 12 (`plan.md`, `tasks.md`), 13 (checkboxes, commands from `plan.md`), 14 (commands, coverage matrix) aligned. | +| Review (2026-10-18) | The entry protocol from `shade-kit-skills.md` failed on re-entry and had no rejection path; package 06 still described a router; ADR 0001 was deleted but cited; `quick` was a skill without its own phase; `feature drop` skeleton omitted `--yes`; `feature current` had no status; six vs. seven research agents; `features` vs. `feature` command group; packages 01 and 19 ignored existing code. | 04 (re-entry, `phase reject`, `quick` phase), 05 (`quick` gate), 06 (entry protocol, `shade-kit-next-phase`), 03 (drop confirmation, `status`), 17 (`quick` phase), 01 and 19 (**Implemented**), ADR decisions moved into concept §9, CLI group renamed to `feature` in the code, router references replaced in 02, 07, 08, 09, 10, 15. | +| Findings clarification (2026-10-18) | Open findings of the 2026-10-18 review ([shade-kit-open-findings.md](../../research/shade-kit-open-findings.md)): short `bugfix` plan without test strategy, DoD in `quick`, DOR-09/DOR-10 in `bugfix`, report and review skills, no `phase next`, phase vs. workflow skills, concept behind the packages, `CLAUDE.md` scope, bare **Decided** labels, two engine rules, unverified release facts. | 02 (`report` in `config.json`), 04 (`phase next`, pending transition), 05 (DOR-10, DOD-01, `n/a` examples), 06 (`phase next`, pending transition, report and review skills, no `research/` paths), 13 (owns review skill), 15 (optional report), 16 (short plan sections, `diagnose / security`), 17 (coverage, playbook on demand, DoD items), 19 (release notes and ruleset facts); concept, `CLAUDE.md`, `shade-kit-skills.md`, skills, and templates aligned; confirmation list emptied; **Decided** labels sourced or downgraded. | +| 02 implementation (2026-10-18) | The draft of 02 appended the managed section to a file that already had one, which broke FP02-AC5 ("exactly one") and contradicted "Adds or updates"; template sources in repository-root `templates/` cannot ship inside the engine; git drops the empty `features/` that `init` creates, so exit code 11 (`features-dir-not-found`) would fail every fresh clone; `gates.json` said "only `schemaVersion`" although 01 stamps `minEngineVersion` everywhere; `init` needed its own exit codes. The code review of the implementation ([report](../reviews/2026-10-18-feature-cli-init-repo-uncommitted.md)) found that a UTF-16 or UTF-32 `.gitignore`, `CLAUDE.md`, or `AGENTS.md` got UTF-8 text appended, that one of these paths existing as a directory failed only after `.shadekit/` was written, that a cancellation left an empty `.shadekit/`, that the trimmed-line `.gitignore` rule counted lines git does not match, and that a file ending with an empty line got a second blank line. | 02 (section replaced between valid markers, invalid markers stop with exit code 23 before any write; template sources moved to `src/CreativeCoders.ShadeKit.Core/Templates/Resources/` and embedded; lock-file shape, `.gitignore` rules, write order without rollback, and output contract recorded; deviations listed; ACs **Implemented**), 01 (missing `features/` counts as empty, exit code 11 removed, 13 `already-initialized` and 23 `invalid-managed-section` added), 02 and 01 after the review (exit codes 14 `path-not-a-file` and 24 `unsupported-encoding` checked before any write; no `.shadekit/` left after a cancellation; `.gitignore` entry present when the line without trailing spaces matches, following git; separator blank line only if the file does not already end with an empty line; review note and new open points in 02), concept §3.1 and §4.5, `CLAUDE.md`, `README.md`, skills aligned. | diff --git a/docs/reviews/2026-10-13-feature-initialsetup-uncommitted-2.md b/docs/reviews/2026-10-13-feature-initialsetup-uncommitted-2.md deleted file mode 100644 index 3ac42bb..0000000 --- a/docs/reviews/2026-10-13-feature-initialsetup-uncommitted-2.md +++ /dev/null @@ -1,137 +0,0 @@ -# .NET Code Review — feature/initialsetup (uncommitted) - -**Date:** 2026-10-13 -**Round:** 2 (re-review after rework; round 1: `docs/reviews/2026-10-13-feature-initialsetup-uncommitted.md`) -**Mode:** uncommitted (working tree vs. HEAD, incl. staged/unstaged/untracked) — origin: provided -**Detected SDK:** 10.0.401 (installed; `global.json` pins no SDK) -**Target Framework(s):** net10.0 -**Version origin:** repo:Directory.Build.props — `detect-dotnet-version.sh` exited 5 ("malformed global.json") because `global.json` deliberately contains only `test.runner` and no `sdk` section. The file is valid JSON, so, as in round 1, the target framework was taken from `net10.0` in `Directory.Build.props` (non-interactive run, no user to ask). -**Checklist:** review-checklist-net10.md (+ security, performance, architecture, code-quality) -**Tools run:** build=N · format=N · test=N (all provided; build with analyzers + `TreatWarningsAsErrors` and 67/67 tests reported green in Phase 4) -**Report language:** English — origin: provided -**Exclusions:** .gitignore, *.min.js, wwwroot/lib/**; additionally `docs/reviews/2026-10-13-feature-initialsetup-uncommitted.md` (round-1 report, excluded by instruction) -**Review strategy:** full (diff below the large-diff thresholds; gate not triggered) -**Diff size:** 32 files, ~1583 changed LOC (the collector reported 33 files / 1788 LOC including the excluded 205-line round-1 report) - -## Executive Summary - -| Severity | Count | -|---|---| -| Critical | 0 | -| Major | 0 | -| Minor | 0 | -| Suggestion | 2 | -| Nitpick | 2 | - -**Top risks:** -1. `ExecuteAsync_Called_WritesToolNameAndVersionWithoutBuildMetadata` derives its expected value from `AssemblyVersion.ToString(3)`. It fails as soon as the project gets a prerelease or four-part ``, for example from the planned release workflow. -2. The "features directory missing" abort message drops the `FeaturesPath` that the new domain exception now carries. With nested kit roots, the user cannot see which `.shadekit` was picked. -3. Small style leftovers: an uncommented `!` in `VersionCommand.GetVersion`, and trailing whitespace in a touched file that the new `.editorconfig` would now trim. - -**Overall:** All six round-1 findings are fixed correctly, and the new code comes with targeted tests: the domain exception and its propagation rules, the faulted task, the build-metadata edge cases, and the skip guard. No Critical, Major, or Minor findings remain. The open items are optional refinements. - -### Round-1 findings — verification - -| # | Round-1 finding | Status | Evidence | -|---|---|---|---| -| 1 | [Minor] machine-dependent "root not found" tests | **Fixed** | **FACT:** `TempKitDirectory.SkipWhenKitRootExistsAbove()` (`TempKitDirectory.cs:32`) calls `Assert.SkipWhen` and walks every ancestor of the temp directory. It is called in all 4 not-found tests (`KitStoreFactoryTests.cs:67,103,159`, `KitStoreFactoryCurrentDirectoryTests.cs:42`). **FACT:** `Assert.SkipWhen` exists in `xunit.v3.assert` 4.0.2 (package XML docs). The guard is a superset of the factory's 20-level search, so it never under-skips. In the twenty-levels test it is even redundant, because the search never gets above the temp root. That is harmless. | -| 2 | [Suggestion] BCL `DirectoryNotFoundException` read as "features missing" | **Fixed** | **FACT:** `FeaturesDirectoryNotFoundException` exposes `FeaturesPath` and puts it in the message. `KitStore.GetFeatureNames` checks `Directory.Exists` first and then throws it (`KitStore.cs:19-22`). `IKitStore` documents it with ``. `FeaturesListCommand` catches only the domain exception (`FeaturesListCommand.cs:68`). Tests cover eager and lazy throws, and confirm that BCL `DirectoryNotFoundException`, `IOException`, and `UnauthorizedAccessException` propagate unwrapped. | -| 3 | [Suggestion] synchronous throw from `ExecuteAsync` | **Fixed** (as decided: `async` without `await`) | **FACT:** `FeaturesListCommand.ExecuteAsync` is `async` (`FeaturesListCommand.cs:41`). `ExecuteAsync_KitRootNotFound_ReturnsFaultedTaskInsteadOfThrowingSynchronously` checks that the call does not throw and that the task is faulted with `CliCommandAbortException`. That no CS1998 is emitted comes from the Phase-4 build report; it was not re-verified here (build=no). | -| 4 | [Suggestion] full commit SHA in version output | **Fixed** | **FACT:** `VersionCommand.GetVersion` cuts at the first `+` and falls back to the assembly version when the result is empty (`VersionCommand.cs:48-56`). The theory covers `+sha`, prerelease+metadata, no metadata, trailing `+`, double `+`, empty, and leading `+`. See the new Suggestion on the end-to-end test below. | -| 5 | [Suggestion] no analyzers | **Fixed** | **FACT:** `Directory.Build.props:7-9` sets `AnalysisLevel=latest-recommended`, `EnforceCodeStyleInBuild=true`, and `TreatWarningsAsErrors=true`. **FACT:** `tests/.editorconfig` disables only CA1707, and only for test sources. **FACT:** both collection classes are named `CurrentDirectoryCollectionDefinition`, and both `[Collection]` usages reference `.Name`. **FACT:** the SDK applies `analysislevelstyle_recommended.globalconfig` via `AnalysisLevelStyle` (which defaults to `AnalysisLevel`), so `EnforceCodeStyleInBuild` does enforce IDE rules and is not a no-op. | -| 6 | [Nitpick] BOM / encoding | **Fixed** | **FACT:** the root `.editorconfig` sets utf-8, lf, final newline, space indentation, and trim of trailing whitespace. **FACT:** none of the 32 changed or new files has a BOM, CRLF line endings, or a missing final newline. | - -## Findings - -### [Suggestion][Tests] tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Version/VersionCommandTests.cs:17 -The end-to-end version test builds its expected value from `AssemblyVersion.ToString(3)`. That holds only while `` is a plain three-part number. - -**FACT:** The SDK derives `AssemblyVersion` from `$(Version)` without its prerelease label (`GetAssemblyVersion` task, `Microsoft.NET.GenerateAssemblyInfo.targets`), while `InformationalVersion` is `$(Version)` plus `+`. So for `-p:Version=1.2.0-beta.1` the command correctly prints `shade-kit 1.2.0-beta.1` but the test expects `shade-kit 1.2.0`. For a four-part `Version=1.2.3.4` it expects `1.2.3` but gets `1.2.3.4`. The release workflow in `CLAUDE.md` is still *(planned)*, and it will most likely set such versions. When it does, this test will fail even though the code is correct. The edge cases are already covered by the `GetVersion_InformationalVersion_…` theory, so this test only needs to prove that the command prints the tool name and the informational version without build metadata. - -```csharp -[Fact] -public async Task ExecuteAsync_Called_WritesToolNameAndVersionWithoutBuildMetadata() -{ - // Arrange - var informationalVersion = typeof(VersionCommand).Assembly - .GetCustomAttribute()! - .InformationalVersion; - var expectedVersion = informationalVersion.Split('+', 2)[0]; - - var console = new TestConsole(); - var command = new VersionCommand(console); - - // Act - await command.ExecuteAsync(); - - // Assert - console.Output.TrimEnd() - .Should().Be($"shade-kit {expectedVersion}") - .And.NotContain("+"); -} -``` - -### [Suggestion][Code-Quality] src/CreativeCoders.ShadeKit.Cli/Commands/Features/List/FeaturesListCommand.cs:72 -The abort message for a missing `features` directory does not name the directory, even though `FeaturesDirectoryNotFoundException.FeaturesPath` now provides it. - -The kit-root-not-found message names the directory it searched from (`FeaturesListCommand.cs:87-88`). The features message only says "the SHADE kit root directory". `KitStoreFactory` picks the *closest* `.shadekit` (covered by `Create_NestedKitRoots_ReturnsStoreOfClosestKitRoot`), so with nested kits the user cannot tell which one is missing `features`. Using the path the exception already carries makes the error actionable and keeps the two messages consistent. `Markup.Escape` is already applied to the whole string, so a path containing `[` or `]` stays safe. - -```csharp -catch (FeaturesDirectoryNotFoundException e) -{ - throw new CliCommandAbortException( - Markup.Escape( - $"The SHADE kit root directory contains no '{KitStorePaths.FeaturesDirName}' directory " + - $"(expected at '{e.FeaturesPath}')."), - ShadeKitCliExitCodes.FeaturesDirectoryNotFound, e); -} -``` - -In `FeaturesListCommandTests.ExecuteAsync_FeaturesDirectoryNotFound_…`, also assert `exception.Message.Should().Contain("/kit/.shadekit/features")`. - -### [Nitpick][Code-Quality] src/CreativeCoders.ShadeKit.Cli/Commands/Version/VersionCommand.cs:52 -`informationalVersion!` uses the null-forgiving operator without a comment. The code is correct: `metadataSeparatorIndex >= 0` implies a non-null string. But the compiler cannot prove that, and the code-quality checklist asks for either a comment or a shape that does not need `!`. A pattern-based form needs neither and is shorter. - -```csharp -internal static string GetVersion(Assembly assembly) -{ - var informationalVersion = assembly - .GetCustomAttribute()? - .InformationalVersion; - - var version = informationalVersion?.Split('+', 2)[0]; - - return string.IsNullOrEmpty(version) - ? assembly.GetName().Version?.ToString() ?? string.Empty - : version; -} -``` - -### [Nitpick][Code-Quality] src/CreativeCoders.ShadeKit.Core/Store/KitStoreFactory.cs:25 -**FACT:** Lines 25, 27, and 45 of this file, which this change set modifies, contain whitespace-only lines with trailing spaces, as do lines 6, 8, and 10 of the unchanged `KitStorePaths.cs`. **FACT:** The unchanged `IKitStoreFactory.cs`, `KitRootPathNotFoundException.cs`, and `KitStorePaths.cs` have no final newline. The new root `.editorconfig` (`trim_trailing_whitespace`, `insert_final_newline`) will now rewrite these lines the next time each file is saved, so later, unrelated diffs pick up noise. Normalizing once, in this change set or in a separate `chore:` commit, keeps future diffs clean. This is formatting only and does not touch the pre-existing `KitStoreFactory` behaviors that are out of scope. - -```csharp -// KitStoreFactory.cs — whitespace-only lines become empty lines -const int maxRecursionDepth = 20; - -var recursionDepth = 0; - -while (recursionDepth++ < maxRecursionDepth) -``` - -(`dotnet format whitespace ShadeKit.slnx` applies the `.editorconfig` rules to all files at once.) - -## Tool Output Appendix - -### dotnet build -- Skipped (build=no; reported clean with `AnalysisLevel=latest-recommended`, `EnforceCodeStyleInBuild`, and `TreatWarningsAsErrors` in Phase 4). - -### dotnet test -- Skipped (test=no; reported 67/67 passed in Phase 4). - -### dotnet format -- Skipped (format=no). - -### Other checks -- `scripts/detect-dotnet-version.sh`: exit 5, "malformed global.json". This is a known artefact of the detector expecting an `sdk` section. The target framework was resolved from `Directory.Build.props` (see metadata). -- Read-only verifications: BOM/CRLF/final-newline scan of all changed files; `Assert.SkipWhen` lookup in the `xunit.v3.assert` 4.0.2 package; SDK `codestyle` targets and `analysislevelstyle_recommended.globalconfig` (code-style enforcement); SDK `GetAssemblyVersion` target (version derivation). diff --git a/docs/reviews/2026-10-13-feature-initialsetup-uncommitted-3.md b/docs/reviews/2026-10-13-feature-initialsetup-uncommitted-3.md deleted file mode 100644 index 3004439..0000000 --- a/docs/reviews/2026-10-13-feature-initialsetup-uncommitted-3.md +++ /dev/null @@ -1,121 +0,0 @@ -# .NET Code Review — feature/initialsetup (uncommitted) - -**Date:** 2026-10-13 -**Round:** 3 (re-review after rework; round 1: `docs/reviews/2026-10-13-feature-initialsetup-uncommitted.md`, round 2: `docs/reviews/2026-10-13-feature-initialsetup-uncommitted-2.md`) -**Mode:** uncommitted (working tree vs. HEAD, incl. staged/unstaged/untracked) — origin: provided -**Detected SDK:** 10.0.401 (installed `dotnet --version`; `global.json` pins no SDK) -**Target Framework(s):** net10.0 -**Version origin:** repo:Directory.Build.props — `detect-dotnet-version.sh` exited 5 ("malformed global.json") because `global.json` deliberately contains only `test.runner` and no `sdk` section. The file is valid JSON, so, as in rounds 1 and 2, the target framework was taken from `net10.0` in `Directory.Build.props:4` (non-interactive run, no user to ask). -**Checklist:** review-checklist-net10.md (+ security, performance, architecture, code-quality) -**Tools run:** build=N · format=N · test=N (all provided; build with analyzers + `TreatWarningsAsErrors` and 67/67 tests reported green in Phase 4) -**Report language:** English — origin: provided -**Exclusions:** .gitignore, *.min.js, wwwroot/lib/**; additionally `docs/reviews/2026-10-13-feature-initialsetup-uncommitted.md` and `docs/reviews/2026-10-13-feature-initialsetup-uncommitted-2.md` (round-1 and round-2 reports, excluded by instruction) -**Review strategy:** full (35 in-scope files / 1601 LOC, below the large-diff thresholds; gate not triggered) -**Diff size:** 35 files, 1601 changed LOC (the collector reported 37 files / 1943 LOC including the two excluded reports) - -## Executive Summary - -| Severity | Count | -|---|---| -| Critical | 0 | -| Major | 0 | -| Minor | 0 | -| Suggestion | 0 | -| Nitpick | 3 | - -**Top risks:** -1. None of the open items affects behavior. The `` doc of `FeaturesDirectoryNotFoundException.FeaturesPath` promises a full path, but `KitStore` passes a relative path through unchanged. -2. `IKitStore` / `IKitStoreFactory`, the Core contracts the CLI depends on, have no type-level XML docs, while the rest of the new public surface is documented. -3. One verbose array initializer remains in a test (`new[] { "features" }`), where a list pattern is shorter. - -**Overall:** All four round-2 findings are fixed correctly, and all round-1 fixes still hold. The change set has no Critical, Major, Minor, or Suggestion findings. The three Nitpicks are optional. The change set is ready to commit from a review standpoint. - -### Round-2 findings — verification - -| # | Round-2 finding | Status | Evidence | -|---|---|---|---| -| A | [Suggestion] `VersionCommandTests` expected `AssemblyVersion.ToString(3)` | **Fixed** | **FACT:** `ExecuteAsync_Called_WritesToolNameAndVersionWithoutBuildMetadata` (`VersionCommandTests.cs:13-31`) takes its expected value from `VersionCommand.GetVersion(typeof(VersionCommand).Assembly)`, checks the console output against `shade-kit {expectedVersion}`, and asserts that the value is not empty and contains no `+`. Nothing ties the test to `AssemblyVersion` any more, so prerelease or four-part `` values no longer break it. The stripping logic is covered with fixed literals by the theory `GetVersion_InformationalVersion_ReturnsVersionWithoutBuildMetadata` (`VersionCommandTests.cs:33-59`: `+sha`, prerelease + metadata, none, trailing `+`, double `+`, empty, leading `+`), and the missing-attribute fallback by `GetVersion_AssemblyWithoutInformationalVersion_ReturnsAssemblyVersion`. One limit remains: because the end-to-end expectation comes from `GetVersion` itself, that test alone would not notice if `GetVersion` returned the assembly version. The theory covers that case, so this is acceptable. | -| B | [Suggestion] features-missing abort message lacked the path | **Fixed** | **FACT:** `FeaturesListCommand.cs:70-74` builds the message as `"... contains no 'features' directory ('{e.FeaturesPath}')."` and wraps the whole string in `Markup.Escape`. **FACT:** `ExecuteAsync_FeaturesDirectoryNotFound_…` (`FeaturesListCommandTests.cs:110-144`) uses `/kit/[nested]/.shadekit/features` and asserts `'/kit/[[nested]]/.shadekit/features'`, which proves that both the path and its escaping reach the message. Exit code, `IsError`, inner exception, and empty console output are still asserted. | -| C | [Nitpick] uncommented `informationalVersion!` | **Fixed** | **FACT:** `VersionCommand.cs:44-51` has no null-forgiving operator. The `?.` chain (`GetCustomAttribute<…>()?.InformationalVersion.Split('+', 2)[0]`) yields `null` when the attribute is missing, and `string.IsNullOrEmpty(version)` falls back to `assembly.GetName().Version?.ToString() ?? string.Empty`. The `` on `ExecuteAsync` (`VersionCommand.cs:29-33`) describe this behavior correctly. | -| D | [Nitpick] trailing whitespace / missing final newline in Core files | **Fixed** | **FACT:** the `git diff HEAD` of `KitStoreFactory.cs`, `KitStorePaths.cs`, `IKitStoreFactory.cs`, `KitRootPathNotFoundException.cs` (and `KitStore.cs`, `IKitStore.cs`) contains only whitespace-only-line → empty-line and final-newline changes in the formerly affected spots. No logic changed, so the out-of-scope `KitStoreFactory` behaviors are untouched. **FACT:** a byte-level scan of all 35 in-scope files finds no trailing whitespace, no missing final newline, no BOM, and no CR characters. | - -Round-1 fixes re-checked in passing, all still in place: the `SkipWhenKitRootExistsAbove` guard in all four not-found tests, catching the domain exception only, `async` `ExecuteAsync` with the faulted-task test, the analyzer settings in `Directory.Build.props:7-9` with CA1707 off only in `tests/.editorconfig`, and the encoding rules in `.editorconfig`. - -## Findings - -### [Nitpick][Code-Quality] src/CreativeCoders.ShadeKit.Core/Store/FeaturesDirectoryNotFoundException.cs:13 -The `` doc says `FeaturesPath` is "the full path of the missing `features` directory". The code does not guarantee that. - -**FACT:** `KitStore` stores its constructor argument unchanged after a whitespace check (`KitStore.cs:9-12`). It builds the path with `Path.Combine(_kitStorePath, "features")` (`KitStore.cs:33-36`) and passes that to the exception (`KitStore.cs:21`). `KitStore` is public, so `new KitStore("relative/.shadekit")` produces a relative `FeaturesPath`. The CLI path through `KitStoreFactory.Create()` starts from `Directory.GetCurrentDirectory()` and is therefore absolute in practice, so this is a documentation inaccuracy only. Since round 2, this value appears in the user-facing abort message. Fix the doc wording, which leaves the out-of-scope factory behaviors alone, rather than the code. - -```csharp -///

-/// Gets the path of the features directory that was not found. -/// -/// -/// The path of the missing features directory, composed from the store path passed to . -/// -public string FeaturesPath { get; } = featuresPath; -``` - -### [Nitpick][Code-Quality] src/CreativeCoders.ShadeKit.Core/Store/IKitStore.cs:3 -`IKitStore` gained member docs in this change set but still has no type-level ``, and `IKitStoreFactory` (`IKitStoreFactory.cs:3-8`) has none at all. - -Every other public type this change set adds or extends is documented: `ShadeKitCliExitCodes`, both commands, `FeaturesCommandGroup`, `ShadeKitCoreServiceCollectionExtensions`, and `FeaturesDirectoryNotFoundException`. The two Core interfaces are the contracts the CLI is written against, and `FeaturesListCommand` documents its behavior in terms of them. The missing pieces are not obvious from the names. `Create()` searches upward from the current directory. `Create(string)` searches upward from `path`, and both throw `KitRootPathNotFoundException`. Today that knowledge exists only in `FeaturesListCommand`'s remarks and in the tests. This is optional. The implementing classes (`KitStore`, `KitStoreFactory`, `KitStorePaths`, `KitRootPathNotFoundException`) can follow in a later change. - -```csharp -/// -/// Provides read access to a SHADE kit store, the .shadekit directory of a repository. -/// -public interface IKitStore -{ - // existing member docs unchanged -} - -/// -/// Locates the SHADE kit root directory and creates an for its .shadekit directory. -/// -public interface IKitStoreFactory -{ - /// - /// Creates the store of the closest SHADE kit root at or above the current directory. - /// - /// The store of the closest SHADE kit root. - /// No SHADE kit root directory is found. - IKitStore Create(); - - /// - /// Creates the store of the closest SHADE kit root at or above . - /// - /// The directory to start the search from. - /// The store of the closest SHADE kit root. - /// No SHADE kit root directory is found. - IKitStore Create(string path); -} -``` - -### [Nitpick][.NET-Idioms] tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Features/List/FeaturesListCommandTests.cs:351 -`x.Commands.SequenceEqual(new[] { "features" })` uses the verbose array initializer that the net10 checklist flags ("Collection expressions" / "Pattern matching — list patterns"). - -**FACT:** `CliCommandGroupAttribute.Commands` is a `string[]` in `CreativeCoders.Cli.Core` 6.10.0. Both `x.Commands is ["features"]` and `x.Commands.SequenceEqual(["features"])` compile against it and return `true` for this attribute. This was checked with a throw-away file-based app in the scratchpad, not in the repo. The list pattern is the shortest form and needs no LINQ. No build analyzer flags this today, because the build is reported warning-free. - -```csharp -groupAttributes - .Should().ContainSingle(x => x.Commands is ["features"]); -``` - -## Tool Output Appendix - -### dotnet build -- Skipped (build=no; reported clean with `AnalysisLevel=latest-recommended`, `EnforceCodeStyleInBuild`, and `TreatWarningsAsErrors` in Phase 4). - -### dotnet test -- Skipped (test=no; reported 67/67 passed in Phase 4). - -### dotnet format -- Skipped (format=no). Whitespace hygiene was checked separately (see "Other checks"). - -### Other checks -- `scripts/detect-dotnet-version.sh`: exit 5, "malformed global.json". This is a known artifact: the detector expects an `sdk` section. The target framework was resolved from `Directory.Build.props` (see metadata). -- `scripts/collect-diff.sh --mode uncommitted`: exit 0, 37 files / 1943 LOC raw. 35 files / 1601 LOC remain after excluding the two prior review reports. -- Read-only verifications: byte-level scan of all 35 in-scope files for trailing whitespace, final newline, BOM, and CR; `git diff HEAD` of the Core store files (whitespace-only changes for finding D); search for `NoWarn` / `#pragma` / `SuppressMessage` (none found); search for checklist anti-patterns (`.Result`, `.Wait()`, `async void`, `catch (Exception)`, `DateTime.Now`, `new Random`, object `lock`, verbose array/list initializers; one hit, reported above); type probe of `CliCommandGroupAttribute.Commands` with a scratch file-based app (`dotnet run probe.cs`, outside the repo). diff --git a/docs/reviews/2026-10-13-feature-initialsetup-uncommitted.md b/docs/reviews/2026-10-13-feature-initialsetup-uncommitted.md deleted file mode 100644 index 75d9ce2..0000000 --- a/docs/reviews/2026-10-13-feature-initialsetup-uncommitted.md +++ /dev/null @@ -1,205 +0,0 @@ -# .NET Code Review — feature/initialsetup (uncommitted) - -**Date:** 2026-10-13 -**Mode:** uncommitted (working tree vs. HEAD, incl. staged/unstaged/untracked) — origin: provided -**Detected SDK:** 10.0.401 (installed `dotnet --version`; `global.json` pins no SDK) -**Target Framework(s):** net10.0 -**Version origin:** repo:Directory.Build.props — `detect-dotnet-version.sh` exited 5 ("malformed global.json") because `global.json` contains only `test.runner` and no `sdk` section. The file is valid JSON and the missing SDK pin is a confirmed design decision, so the target framework was resolved manually from `net10.0` in `Directory.Build.props` (non-interactive run, no user to ask). -**Checklist:** review-checklist-net10.md (+ security, performance, architecture, code-quality) -**Tools run:** build=N · format=N · test=N (all provided; build and 56/56 tests reported green in Phase 4) -**Report language:** English — origin: provided -**Exclusions:** .gitignore, *.min.js, wwwroot/lib/** -**Review strategy:** full (diff below the large-diff thresholds; gate not triggered) -**Diff size:** 27 files, 1362 changed LOC - -## Executive Summary - -| Severity | Count | -|---|---| -| Critical | 0 | -| Major | 0 | -| Minor | 1 | -| Suggestion | 4 | -| Nitpick | 1 | - -**Top risks:** -1. Four "kit root not found" tests only pass if no `.shadekit` directory exists anywhere above the system temp directory, so they depend on the machine they run on. -2. `FeaturesListCommand` treats any `DirectoryNotFoundException` from `IKitStore` as "features directory missing". That ties the CLI to an undocumented detail of `KitStore` instead of a domain exception, which is how the root-not-found case already works. -3. `shade-kit version` prints the full 40-character commit SHA (**FACT**: observed `shade-kit 1.0.0+4355d63c184d1586407d6b3c714ff47e07b3e1f1`). For builds from a dirty working tree, that SHA is HEAD's, not the code that was actually built. - -**Overall:** A clean, well-tested CLI skeleton. The confirmed design decisions are implemented as described: `CliCommandAbortException` with `Markup.Escape`d messages, `ToProcessExitCode` mapping negative codes to 255 (**FACT**: an unknown command exits 255, a missing kit root exits 1), the `KitStoreFactory.Create(string)` fix, CPM, and MTP/coverlet.MTP. There are no Critical or Major findings. The remaining items are test robustness and small design refinements. - -Verified facts used in this review: -- **FACT:** `ICliCommand`, `ICliCommand`1`, `CliCommandGroupAttribute`, `CommandResult` exist in `CreativeCoders.Cli.Core.dll`; `CliCommandAbortException`, `CliExitCodes`, `PrintFooterText` exist in `CreativeCoders.Cli.Hosting.dll` 6.10.0, so the conventions documented in `CLAUDE.md` match the package. -- **FACT:** `dotnet test --coverlet` is the documented coverlet.MTP 10.1.0 switch (`Coverlet.MTP.Integration.md`). -- **FACT:** `Spectre.Console.Testing` 0.57.2 matches the transitive `Spectre.Console` 0.57.2 that `CreativeCoders.Cli.Hosting` 6.10.0 pulls in, so there is no version skew. -- **FACT:** `TestResults/`, `bin/` and `obj/` are git-ignored (`.gitignore` lines 23, 36). - -## Findings - -### [Minor][Tests] tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreFactoryTests.cs:63 -The "not found" tests depend on the environment: they pass only if no `.shadekit` directory exists above the system temp directory. - -`KitStoreFactory` walks up to 20 parent directories. These tests create their tree under `Directory.CreateTempSubdirectory(...)` and expect `KitRootPathNotFoundException`: -- `Create_NoKitRootInPathOrParents_ThrowsKitRootPathNotFoundException` (line 63) -- `Create_KitRootTwentyLevelsAbovePath_ThrowsKitRootPathNotFoundException` (line 98) -- `Create_ShadeKitIsAFile_ThrowsKitRootPathNotFoundException` (line 153) -- `KitStoreFactoryCurrentDirectoryTests.Create_NoKitRootAboveCurrentDirectory_ThrowsKitRootPathNotFoundException` (`KitStoreFactoryCurrentDirectoryTests.cs:38`) - -If any ancestor of the temp directory contains `.shadekit`, these tests fail. For example, `TMPDIR` might point into a CI workspace or into a repository that dogfoods SHADE kit, or a user might have `/tmp/.shadekit` or `/.shadekit`. Once the kit installs `.shadekit` into repositories, possibly this one, the risk becomes real. Guard the precondition explicitly so that a polluted environment gives a clear failure, or skips the test, instead of an obscure assertion error. - -```csharp -// TempKitDirectory.cs: expose a precondition check -public bool HasKitRootAbove() -{ - for (var dir = _directory.Parent; dir is not null; dir = dir.Parent) - { - if (Directory.Exists(Path.Combine(dir.FullName, KitStorePaths.RootPath))) - { - return true; - } - } - - return false; -} - -// In each "not found" test (xUnit v3): -Assert.SkipWhen(tempDirectory.HasKitRootAbove(), - "A '.shadekit' directory exists above the temp directory; the not-found scenario cannot be tested here."); -``` - -### [Suggestion][Architecture] src/CreativeCoders.ShadeKit.Cli/Commands/Features/List/FeaturesListCommand.cs:68 -The command reads a BCL `DirectoryNotFoundException` from `IKitStore` as "the kit root has no `features` directory". - -`IKitStore.GetFeatureNames()` declares no such contract. The mapping relies on how `KitStore` happens to be implemented today (`Directory.EnumerateDirectories`). The same exception can also mean that the `.shadekit` directory itself disappeared after the lookup, and the message would then be wrong. Core already uses a domain exception for the parallel case (`KitRootPathNotFoundException`). Changing `KitStore`'s behavior is outside this change set, so consider this a follow-up: give Core a `FeaturesDirectoryNotFoundException` and let the CLI catch that instead. - -```csharp -// Core (follow-up) -public class FeaturesDirectoryNotFoundException(string featuresPath, Exception? innerException = null) - : Exception($"Features directory '{featuresPath}' not found.", innerException) -{ - public string FeaturesPath { get; } = featuresPath; -} - -// KitStore.GetFeatureNames -var featuresPath = GetFeaturesPath(); -if (!Directory.Exists(featuresPath)) -{ - throw new FeaturesDirectoryNotFoundException(featuresPath); -} - -// FeaturesListCommand.GetFeatureNames -catch (FeaturesDirectoryNotFoundException e) -{ - throw new CliCommandAbortException( - Markup.Escape($"The SHADE kit root directory contains no '{KitStorePaths.FeaturesDirName}' directory."), - ShadeKitCliExitCodes.FeaturesDirectoryNotFound, e); -} -``` - -### [Suggestion][Code-Quality] src/CreativeCoders.ShadeKit.Cli/Commands/Features/List/FeaturesListCommand.cs:41 -`ExecuteAsync` returns a `Task` but throws `CliCommandAbortException` and propagates other exceptions synchronously, rather than returning a faulted task. - -This works today: the host awaits the call inside its try/catch, and AwesomeAssertions' `ThrowAsync` handles synchronous throws. But Task-returning methods are expected to report non-usage exceptions through the returned task. A caller that stores the task first, or that composes it (for example `Task.WhenAll` or a pre/post-processor), would see the exception at a different point. Separating the synchronous body from the Task wrapper costs almost nothing and makes the behavior uniform. A bare `async` without any `await` would trigger CS1998. - -```csharp -public Task ExecuteAsync() -{ - try - { - return Task.FromResult(Execute()); - } - catch (Exception e) - { - return Task.FromException(e); - } -} - -private CommandResult Execute() -{ - var featureNames = GetFeatureNames(); - - if (featureNames.Length == 0) - { - _ansiConsole.WriteLine("No features found."); - - return CommandResult.Success; - } - - foreach (var featureName in featureNames) - { - _ansiConsole.WriteLine(featureName); - } - - return CommandResult.Success; -} -``` - -### [Suggestion][Code-Quality] src/CreativeCoders.ShadeKit.Cli/Commands/Version/VersionCommand.cs:43 -`shade-kit version` prints the full SourceLink revision, for example `shade-kit 1.0.0+4355d63c184d1586407d6b3c714ff47e07b3e1f1` (**FACT**: observed with the current Debug build). - -The .NET SDK adds `+` to `AssemblyInformationalVersion` by default. For a user-facing version string, this is long. For local builds of uncommitted changes, it is also misleading, because it names HEAD rather than the code that was built. Decide this on purpose: either keep it (useful for bug reports), shorten the SHA, or turn it off in the CLI project with `false`. The existing test (`VersionCommandTests.cs:13`) reads the attribute at runtime, so it keeps passing with any of these choices. - -```csharp -internal static string GetVersion(Assembly assembly) -{ - var informationalVersion = assembly.GetCustomAttribute()?.InformationalVersion; - - if (informationalVersion is not null) - { - // Shorten "1.0.0+<40-char sha>" to "1.0.0+<7-char sha>" - var plusIndex = informationalVersion.IndexOf('+'); - return plusIndex >= 0 && informationalVersion.Length > plusIndex + 8 - ? informationalVersion[..(plusIndex + 8)] - : informationalVersion; - } - - return assembly.GetName().Version?.ToString() ?? string.Empty; -} -``` - -### [Suggestion][Code-Quality] Directory.Build.props:3 -The shared build settings enable `Nullable` and `ImplicitUsings`, but nothing for analyzers or warnings. `CLAUDE.md` names `Directory.Build.props` as the home for "nullable, implicit usings, analyzers", and the net10 checklist recommends `TreatWarningsAsErrors`. - -Turning this on while the codebase is still tiny is cheap, and it stops warnings from piling up before the quality gates (build/lint) of v1 exist. - -```xml - - net10.0 - enable - enable - latest-recommended - true - true - -``` - -### [Nitpick][Code-Quality] tests/CreativeCoders.ShadeKit.Cli.Tests/CreativeCoders.ShadeKit.Cli.Tests.csproj:1 -File encodings are inconsistent. **FACT:** both new test `.csproj` files (`tests/CreativeCoders.ShadeKit.Cli.Tests/...csproj`, `tests/CreativeCoders.ShadeKit.Core.Tests/...csproj`) start with a UTF-8 BOM, while this change removes the BOM from both `src/**/*.csproj` files and `Program.cs`. The repository has no `.editorconfig` to settle the question, so IDEs will keep flipping it. - -Pick one convention and record it in an `.editorconfig` at the repository root. - -```ini -# .editorconfig -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -``` - -## Tool Output Appendix - -### dotnet build -- Skipped (build=no; reported green in Phase 4). - -### dotnet test -- Skipped (test=no; reported 56/56 passed in Phase 4). - -### dotnet format -- Skipped (format=no). - -### Other checks -- `scripts/detect-dotnet-version.sh`: exit 5, "malformed global.json". This is an artefact of the detector expecting an `sdk` section; the target framework was resolved from `Directory.Build.props` (see metadata). -- The existing Debug build was run manually to observe runtime behavior: `version` → exit 0; `features list` outside a kit → red abort message, exit 1; unknown command → exit 255. diff --git a/docs/reviews/2026-10-14-feature-initialsetup-uncommitted-2.md b/docs/reviews/2026-10-14-feature-initialsetup-uncommitted-2.md deleted file mode 100644 index ad0e4c4..0000000 --- a/docs/reviews/2026-10-14-feature-initialsetup-uncommitted-2.md +++ /dev/null @@ -1,183 +0,0 @@ -# .NET Code Review — feature/initialsetup (uncommitted) - -**Date:** 2026-10-14 -**Mode:** uncommitted (provided) -**Detected SDK:** not pinned (`global.json` has no `sdk.version`); installed SDK: 10.0.402 -**Target Framework(s):** net10.0 -**Version origin:** repo:Directory.Build.props. `detect-dotnet-version.sh` exited 5 ("malformed global.json") because `global.json` contains only `test.runner` and no `sdk` section. The file is valid JSON, so this is a false positive of the script. No user was reachable (sub-agent), so the review continued version-aware with the TFM from `Directory.Build.props`. -**Checklist:** review-checklist-net10.md, plus the general security, performance, architecture, and code-quality checklists -**Tools run:** build=N · format=N · test=N (all three `provided`) -**Report language:** English (provided) -**Exclusions:** .gitignore, *.min.js, wwwroot/lib/** -**Review strategy:** chunked (auto-selected, non-interactive; diff exceeds 2000 LOC and 50 files) -**Diff size:** 74 files, 7327 changed LOC -**Review type:** re-review after a rework loop; previous report `docs/reviews/2026-10-14-feature-initialsetup-uncommitted.md` - -## Executive Summary - -| Severity | Count | -|---|---| -| Critical | 0 | -| Major | 0 | -| Minor | 1 | -| Suggestion | 3 | -| Nitpick | 2 | - -**Scope:** The same working tree as the previous review (feature package 01, `docs/feature-packages/01-store-foundation-and-format-versioning.md`, ADR 0001), plus the rework. Since the previous report, these files changed: `Program.cs`, `Output/TrackingCliJsonWriter.cs`, `Store/KitStore.cs`, `Formats/VersionedJsonSerializer.cs`, `ProgramTests.cs`, `TrackingCliJsonWriterTests.cs`, and `KitStoreJsonTests.cs`. The previous report itself is now also part of the uncommitted diff. The changed files were reviewed in detail. The other files were checked again against the checklists. - -**Verification of the fixed findings:** - -| ID | Previous finding | Result | -|---|---|---| -| M1 | Plain-mode unexpected exception crashed with exit 134 | **Fixed.** FACT: `Program.cs:54-61` catches every exception, writes `e.ToString()` to stderr, and returns 255 in plain mode. Built binary, unreadable `.shadekit/features`: `features list` exits 255 with the `UnauthorizedAccessException` trace on stderr and nothing on stdout. Covered by `RunAsync_UnexpectedExceptionPlain_ReturnsHostErrorWithDetailsOnStandardErrorAndNoEnvelope`. | -| M2 | JSON mode depended on the option parser | **Fixed, one edge case left** (see the Minor finding). FACT (built binary): `--json version`, `version --json=true`, `version --json 0`, and `version --help --json` each write exactly one `host-error` envelope to stdout and exit 255. `version --json --json` and `version --json True` succeed. `version --json=false` and `version --json False` stay in plain mode. Covered by four new or changed `ProgramTests`. | -| M3 | Host-error envelope written even after the command's own envelope | **Fixed.** FACT: every host-error path goes through `WriteHostError` → `TrackingCliJsonWriter.WriteIfNothingWritten` (`Program.cs:124-130`), so a second document is never written. Covered at unit level by `TrackingCliJsonWriterTests` (including the case where the inner writer throws). The `Program`-level branches have no test (see Suggestion). | -| S3 | Conditional logic in a test theory | **Fixed.** FACT: `KitStoreJsonTests.cs:93` (`ReadJsonAsync_OlderVersionWithoutConfigFile_...`, `[Fact]`) and `:114` (theory over `"migrate-and-warn"` / `null`, always writes `config.json`). No `if` is left in either body. | -| N1 | `!` without a comment | **Fixed.** FACT: comments added at `KitStore.cs:103` and `VersionedJsonSerializer.cs:82`. | -| N2 | `IsConfigFile` used ordinal comparison | **Fixed.** FACT: `KitStore.cs:151-159` uses `OrdinalIgnoreCase` on Windows and macOS, `Ordinal` elsewhere. Covered by `ReadJsonAsync_OutdatedConfigItselfByOtherCaseOnCaseInsensitivePlatform_ReturnsOneWarning`, which skips itself on case-sensitive file systems. | - -**Accepted, unchanged (not re-reported):** S1, which is that `CliErrorMapper.MapWarning` has no production caller yet (the first read command comes with package 03), and S2, which is that a symlink inside `.shadekit` can get past the string-based outside-root check. - -**Acceptance criteria:** FP01-AC1 through FP01-AC12 remain implemented. The AC8/AC12 gap from the previous review is closed for every realistic spelling of `--json`. The agreed design decisions named by the calling workflow were treated as intended. - -**Top risks:** -1. If `--json=false` comes before a bare `--json`, the host runs in plain mode while the command runs in JSON mode. Unexpected failures then return 255 with an empty stdout, and successful runs put an extra blank line after the envelope (verified with the built binary). -2. The two tests that make `.shadekit/features` unreadable fail when the suite runs as root, which is common in CI containers. -3. No `` is set anywhere, so development builds report engine version `1.0.0`, while the format table says the first engine is `0.1.0`. - -**Overall:** The rework fixes all six findings correctly and adds tests for each one. Only one small edge case is left in JSON-mode detection, and it needs an unusual combination of arguments. Nothing blocks shipping. - -## Findings — src/CreativeCoders.ShadeKit.Cli/Program.cs - -### [Minor][Code-Quality] src/CreativeCoders.ShadeKit.Cli/Program.cs:82 -`IsJsonMode` and the option parser can still disagree. If `--json=false` comes before a bare `--json`, `Program` picks plain mode, but the parser sets `Json = true`, so the command writes an envelope. - -FACT (built binary): the option parser ignores `--json=` tokens, and `IsJsonMode` returns at the first `--json` token of either form. `features list --json=false --json` in a repository with an unreadable `features` directory exits 255 with **0 bytes on stdout**, so there is no envelope. On success, the same arguments write the envelope followed by the host's empty footer line (`}\n\n`), because console output goes to stdout in plain mode. This is the one input left where the rule "every `--json` run writes exactly one envelope" (FP01-AC8/AC12) does not hold. The input is unlikely, but the fix is local. Let a bare `--json` decide first, as the parser does, and look at the `--json=` form only when no bare switch exists. - -```csharp -private static bool IsJsonMode(string[] args) -{ - var jsonSwitch = "--" + JsonCommandOptions.JsonOptionName; - - // The option parser reads only the bare switch (first occurrence wins), so it decides first. - var bareIndex = Array.IndexOf(args, jsonSwitch); - - if (bareIndex >= 0) - { - return bareIndex + 1 >= args.Length || !IsFalse(args[bareIndex + 1]); - } - - // "--json=" is not parsed by the host; treat it as JSON mode so the run gets a host-error envelope. - var assignment = args.FirstOrDefault(arg => arg.StartsWith(jsonSwitch + "=", StringComparison.Ordinal)); - - return assignment is not null && !IsFalse(assignment[(jsonSwitch.Length + 1)..]); -} -``` - -Add `["version", "--json=false", "--json"]` to a JSON-mode test, and assert that stdout parses as one JSON document. - -### [Suggestion][Tests] src/CreativeCoders.ShadeKit.Cli/Program.cs:69 -The M3 fix is tested only on `TrackingCliJsonWriter`. Two `Program` branches have no test: the command already wrote an envelope and then an exception escapes (line 58), and the host returns a negative exit code after an envelope (line 69). - -`RunAsync` builds the host itself and scans only the CLI assembly, so a test cannot add a command that writes and then fails. If someone later replaces `WriteHostError` with a direct `jsonWriter.Write`, no test fails. Move the decision after the run into a small internal method and test it directly. - -```csharp -internal static int CompleteJsonRun(int hostExitCode, TrackingCliJsonWriter jsonWriter) -{ - if (hostExitCode < 0) - { - return WriteHostError(jsonWriter, - $"The command could not be run (host exit code {hostExitCode}). Details were written to standard error."); - } - - return jsonWriter.HasWritten - ? hostExitCode - : WriteHostError(jsonWriter, "The command wrote no JSON result. ..."); -} - -// Test: envelope already written + negative exit code -> 255 and still exactly one document. -``` - -### [Nitpick][Code-Quality] src/CreativeCoders.ShadeKit.Cli/Program.cs:56 -In plain mode, an unexpected exception produces only the raw `e.ToString()` trace. JSON mode, by contrast, starts with "The command failed unexpectedly: …". - -The first trace line includes the exception type and message, so it is readable. A short leading line would still make the two modes match and tell users that this is an internal failure, not a usage error. - -```csharp -await error.WriteLineAsync($"shade-kit failed unexpectedly: {e.Message}").ConfigureAwait(false); -await error.WriteLineAsync(e.ToString()).ConfigureAwait(false); -``` - -## Findings — Directory.Build.props - -### [Suggestion][Architecture] Directory.Build.props:3 -No `` / `` is set in the repository. As a result, `EngineVersionProvider` reports the SDK default `1.0.0`, while `FormatRegistry` stamps `minEngineVersion` `"0.1.0"` as the first engine version. - -FACT: `shade-kit version --json` (Debug build) returns `"engineVersion": "1.0.0"`, and `grep -i version src/*/*.csproj Directory.Build.props` finds no version property. Today nothing compares the two values. Once skills run the version handshake (package 06), a development build that claims `1.0.0` passes every `>= 0.x` check, including checks for features it does not have. Setting the version prefix in one place makes the engine version and the format table agree. The release workflow can still override it. - -```xml - - net10.0 - - 0.1.0 - ... - -``` - -## Findings — tests/CreativeCoders.ShadeKit.Cli.Tests/ProgramTests.cs - -### [Suggestion][Tests] tests/CreativeCoders.ShadeKit.Cli.Tests/ProgramTests.cs:181 -`RunAsync_UnexpectedExceptionJson_...` (line 178) and `RunAsync_UnexpectedExceptionPlain_...` (line 454) cause the failure with `File.SetUnixFileMode(featuresPath, UnixFileMode.None)`. They are skipped only on Windows. - -When the tests run as root, which is common in Docker-based CI images, file modes are ignored. The directory stays readable, `features list` succeeds with exit 0, and both tests fail. Check after the `chmod` that the directory really is unreadable, and skip the test otherwise. - -```csharp -File.SetUnixFileMode(featuresPath, UnixFileMode.None); - -Assert.SkipWhen(CanEnumerate(featuresPath), - "File modes are not enforced for this user (for example root), so the directory stays readable."); - -static bool CanEnumerate(string path) -{ - try - { - _ = Directory.EnumerateDirectories(path).Any(); - return true; - } - catch (UnauthorizedAccessException) - { - return false; - } -} -``` - -### [Nitpick][Tests] tests/CreativeCoders.ShadeKit.Cli.Tests/ProgramTests.cs:159 -`RunAsync_JsonSwitchWithTrueValue_UsesJsonMode` does not check the exit code. It and `RunAsync_JsonSwitchTwice_WritesSuccessEnvelope` (line 139) also lack the `[Trait("Requirement", "FP01-AC12")]` that the other JSON-mode tests carry. - -Without the exit-code check, a run that writes `ok: true` but ends with 255 would still pass. Without the trait, the requirement-to-test mapping misses the M2 cases. - -```csharp -[Fact] -[Trait("Requirement", "FP01-AC12")] -public async Task RunAsync_JsonSwitchWithTrueValue_UsesJsonMode() -{ - // ... - var exitCode = await Program.RunAsync(["version", "--json", "true"], output, error); - - // Assert - exitCode - .Should().Be(0); - // ... -} -``` - -## Tool Output Appendix - -### dotnet build -- Skipped (build=no, provided). The calling workflow reported a green build. For ad-hoc verification of M1, M2, and the Minor finding, the existing Debug binary `src/CreativeCoders.ShadeKit.Cli/bin/Debug/net10.0/shade-kit` was used. Its `shade-kit.dll` is newer than `Program.cs` and `TrackingCliJsonWriter.cs`. The only later source changes (`KitStore.cs`, `VersionedJsonSerializer.cs`) are N1 comments and the N2 comparison, which do not affect these checks. - -### dotnet test -- Skipped (test=no, provided). The calling workflow reported 400/400 tests passing; not re-run here. - -### dotnet format -- Skipped (format=no, provided). diff --git a/docs/reviews/2026-10-14-feature-initialsetup-uncommitted.md b/docs/reviews/2026-10-14-feature-initialsetup-uncommitted.md deleted file mode 100644 index 5019458..0000000 --- a/docs/reviews/2026-10-14-feature-initialsetup-uncommitted.md +++ /dev/null @@ -1,206 +0,0 @@ -# .NET Code Review — feature/initialsetup (uncommitted) - -**Date:** 2026-10-14 -**Mode:** uncommitted (provided) -**Detected SDK:** not pinned (`global.json` has no `sdk.version`); installed SDK used for ad-hoc verification: 10.0.402 -**Target Framework(s):** net10.0 -**Version origin:** repo:Directory.Build.props — read manually; `detect-dotnet-version.sh` exited 5 ("malformed global.json") because `global.json` contains only `test.runner` and no `sdk.version`. The file is valid JSON; the script's check is a false positive. No user was reachable (sub-agent), so the review continued version-aware with the TFM from `Directory.Build.props`. -**Checklist:** review-checklist-net10.md, plus the general security, performance, architecture, and code-quality checklists -**Tools run:** build=N · format=N · test=N (all three `provided`) -**Report language:** English (provided) -**Exclusions:** .gitignore, *.min.js, wwwroot/lib/** -**Review strategy:** chunked (auto-selected, non-interactive; diff exceeds 2000 LOC and 50 files) -**Diff size:** 71 files, 6900 changed LOC - -## Executive Summary - -| Severity | Count | -|---|---| -| Critical | 0 | -| Major | 0 | -| Minor | 3 | -| Suggestion | 3 | -| Nitpick | 2 | - -**Scope:** Feature package 01 (store foundation and format versioning, `docs/feature-packages/01-store-foundation-and-format-versioning.md`, ADR 0001). The diff covers the `Formats` subsystem in Core (`FormatDefinition`, `FormatRegistry`, `VersionedDocument`, `VersionedJsonSerializer`, and the format exceptions), `KitConfig` and its policy enum, `EngineVersionProvider`, the reworked `KitStore`/`KitStoreFactory` (git-root-anchored kit root, `ReadJsonAsync`/`WriteJsonAsync`), the CLI agent contract (`CliJsonEnvelope`, `CliJsonWriter`, `CliErrorMapper`, `ShadeKitCliErrorCodes`, the new exit codes), the JSON-mode host wrapper in `Program.RunAsync`, `version --json`, `features list --json`, a `CLAUDE.md` update, and 280 test methods across both test projects. Two obsolete files (`Config/ConfigWithVersion.cs`, `Config/StoreConfig.cs`) are deleted. - -**Acceptance criteria:** FP01-AC1 through FP01-AC12 are implemented. AC10 is proven at the `IKitStore` level only, because no CLI command reads a versioned file yet. AC8 and AC12 have a gap: when `--json` comes before the command name, the run is not recognised as JSON mode (see the Program.cs findings). The agreed design decisions listed by the calling workflow were treated as intended and are not reported as findings. - -**Top risks:** -1. In plain mode, an unexpected exception crashes the process with exit code 134 (SIGABRT) and a .NET crash trace, not with the host-error code 255 (verified with an unreadable `features` directory). -2. JSON mode depends on how the option parser reads `--json`: `shade-kit --json version`, `version --json=true`, and `version --json --json` all print plain host-error text to stdout and exit 255 with no envelope (verified against the built binary). -3. `Program.RunAsync` writes a host-error envelope without checking whether the command already wrote one. A future command that writes an envelope and then fails unexpectedly would put two JSON documents on stdout. - -**Overall:** A carefully built, well-documented, and thoroughly tested foundation. Versioning, migration, unknown-field preservation, and the pre-write check behave as the requirement specifies. The remaining issues sit at the edges of the agent contract (how JSON mode is detected and the exactly-one-envelope guarantee) and in plain-mode crash handling. None of them blocks shipping. - -## Findings — src/CreativeCoders.ShadeKit.Cli/Program.cs - -### [Minor][Code-Quality] src/CreativeCoders.ShadeKit.Cli/Program.cs:43 -In plain mode, unexpected exceptions are not caught, so the process crashes with exit code 134 and a .NET "Unhandled exception" trace instead of returning a stable host-error exit code. - -FACT: running `shade-kit features list` in a repository whose `.shadekit/features` directory has mode `000` printed `Unhandled exception. System.UnauthorizedAccessException ...` and exited with 134. The same run with `--json` correctly produced a `host-error` envelope and exit 255. Package 01 states that every failure has a stable exit code and that 255 is reserved for host errors. `RunAsync_UnexpectedExceptionPlain_PropagatesExceptionWithoutEnvelope` pins the current behaviour, so it looks deliberate, but it gives users an unstable exit code (134 on Unix, a different value on Windows), possible OS crash reports, and a stack trace in place of a message. Catch unexpected exceptions in plain mode as well, write a short message to stderr, and return `HostError`. Then update the pinning test. - -```csharp -if (!IsJsonMode(args)) -{ - try - { - return ShadeKitCliExitCodes.ToProcessExitCode( - await CreateHost(new CliJsonWriter(output), output).RunMainAsync(args).ConfigureAwait(false)); - } - catch (Exception e) - { - await error.WriteLineAsync($"shade-kit failed unexpectedly: {e.Message}").ConfigureAwait(false); - await error.WriteLineAsync(e.ToString()).ConfigureAwait(false); - - return ShadeKitCliExitCodes.HostError; - } -} -``` - -### [Minor][Architecture] src/CreativeCoders.ShadeKit.Cli/Program.cs:77 -JSON-mode detection uses the option parser's reading of `--json`. Common spellings an agent may use are not recognised, so the agent gets plain text on stdout and no envelope. - -FACT (built binary, `bin/Debug/net10.0/shade-kit`): `--json version`, `version --json=true`, `version --json maybe`, and `version --json --json` each print `No command found for given arguments: ...` to stdout and exit 255, with no JSON. `RunAsync_JsonSwitchBeforeCommand_TakesCommandAsSwitchValueAndUsesPlainMode` documents the first case. The requirement says every `--json` output, success or failure, carries the envelope (FP01-AC12) and that every error in JSON mode produces a JSON document (FP01-AC8). An agent that puts global flags first, which is a common CLI habit, breaks this contract. Decide JSON mode from the raw tokens: treat `--json` (and `--json=true`) anywhere in `args` as JSON mode unless it is explicitly followed by `false`. Keep the host's own parsing unchanged. - -```csharp -private static bool IsJsonMode(string[] args) -{ - const string Switch = "--" + JsonCommandOptions.JsonOptionName; - - for (var i = 0; i < args.Length; i++) - { - if (args[i].Equals(Switch + "=true", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (args[i] == Switch) - { - return i + 1 >= args.Length || !args[i + 1].Equals("false", StringComparison.OrdinalIgnoreCase); - } - } - - return false; -} -``` - -### [Minor][Code-Quality] src/CreativeCoders.ShadeKit.Cli/Program.cs:58 -The `catch` branch (line 58) and the negative-exit-code branch (line 64) always write a `host-error` envelope, even when the command already wrote one. In that case stdout holds two JSON documents, which breaks the agreed rule of exactly one envelope on stdout. - -No current command triggers this. `FeaturesListCommand` writes its envelope as its last action, and failure envelopes are followed by a `CliCommandAbortException` that the host turns into a positive exit code. But the guarantee depends on every future command never failing after `Write`. `TrackingCliJsonWriter.HasWritten` is already there; use it so that no second document is written, and only adjust the exit code. - -```csharp -catch (Exception e) -{ - await error.WriteLineAsync(e.ToString()).ConfigureAwait(false); - - return jsonWriter.HasWritten - ? ShadeKitCliExitCodes.HostError - : WriteHostError(output, - $"The command failed unexpectedly: {e.Message} Details were written to standard error."); -} - -if (exitCode < 0) -{ - return jsonWriter.HasWritten - ? ShadeKitCliExitCodes.HostError - : WriteHostError(output, - $"The command could not be run (host exit code {exitCode}). Details were written to standard error."); -} -``` - -## Findings — src/CreativeCoders.ShadeKit.Cli/Output/CliErrorMapper.cs - -### [Suggestion][Tests] src/CreativeCoders.ShadeKit.Cli/Output/CliErrorMapper.cs:65 -`MapWarning` has no production caller. No code path turns `VersionedReadResult.Warnings` into envelope `warnings`, so FP01-AC10 ("a read command ... returns its result with an `upgrade-needed` warning") is verified only at the store level. - -That is reasonable while no command reads a versioned file. But the CLI side of AC10 (warning code, message, exit code 0, `ok: true`) has no end-to-end test, and every later read command will need the same plumbing. Consider adding a small shared helper that builds a success envelope from a `VersionedReadResult`, and covering it once with a test, so package 02+ commands cannot forget the warnings. - -```csharp -internal static class CliJsonEnvelopeFactory -{ - public static CliJsonEnvelope Success(TData data, IEnumerable formatWarnings) - where TData : notnull - { - return CliJsonEnvelope.Success(data, formatWarnings.Select(CliErrorMapper.MapWarning)); - } -} -``` - -## Findings — src/CreativeCoders.ShadeKit.Core/Store/KitStore.cs - -### [Suggestion][Security] src/CreativeCoders.ShadeKit.Core/Store/KitStore.cs:167 -The containment check in `GetFullPath` compares path strings only. Symbolic links inside `.shadekit/` can still send reads and writes outside the kit root (OWASP A01/A04). - -`Path.GetFullPath` does not resolve links. If `.shadekit/features/x` is a directory symlink to some other location, `WriteJsonAsync("features/x/feature.json", ...)` passes the check and creates the file at the link target. FACT (verified on .NET 10.0.402, macOS): `File.Exists` returns `true` for a dangling file symlink, and `File.WriteAllTextAsync` follows the link and creates the target outside the root. In `KitStore`, the pre-write check reads first, so a dangling file symlink currently ends in an unmapped `FileNotFoundException` (host error 255) rather than a write. Directory symlinks are not caught. The impact is low: file names are fixed by the engine and content is engine-generated. The XML docs promise "points outside RootPath", though, and `.shadekit/` comes from cloned repositories. Either reject link segments below `RootPath`, or document that symlinks are followed. - -```csharp -private void EnsureNoLinkBelowRoot(string fullPath) -{ - for (var current = new FileInfo(fullPath) as FileSystemInfo; - current is not null && current.FullName.Length > RootPath.Length; - current = Directory.GetParent(current.FullName)) - { - if (current.Exists && current.LinkTarget is not null) - { - throw new ArgumentException($"The path '{fullPath}' contains a symbolic link.", nameof(fullPath)); - } - } -} -``` - -### [Nitpick][Code-Quality] src/CreativeCoders.ShadeKit.Core/Store/KitStore.cs:103 -The null-forgiving operator is used without a comment, here and in `src/CreativeCoders.ShadeKit.Core/Formats/VersionedJsonSerializer.cs:82`. - -Both are safe. `fullPath` always lies below `RootPath`, so it has a parent, and serializing a non-null `VersionedDocument` never yields a null node. The code-quality checklist asks for a short reason with each `!`. - -```csharp -// fullPath is always below RootPath (checked in GetFullPath), so it has a parent directory. -Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); -``` - -### [Nitpick][Code-Quality] src/CreativeCoders.ShadeKit.Core/Store/KitStore.cs:152 -`IsConfigFile` compares full paths with ordinal `==`. On case-insensitive file systems (default macOS, Windows), `ReadJsonAsync("Config.json", FormatIds.Config, FormatAccess.Read)` on an outdated config reads the same physical file but is not recognised as the config itself, so the `upgrade-needed` warning is reported twice. - -All callers use `KitStorePaths.ConfigFileName`, so this is unlikely in practice. A comparison that matches the platform avoids the duplicate. - -```csharp -private bool IsConfigFile(string relativePath) -{ - var comparison = OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; - - return string.Equals(GetFullPath(relativePath), GetFullPath(KitStorePaths.ConfigFileName), comparison); -} -``` - -## Findings — tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreJsonTests.cs - -### [Suggestion][Tests] tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreJsonTests.cs:105 -`ReadJsonAsync_OlderVersionWithMigrateAndWarnOrNoSetting_ReturnsMigratedDocumentWithWarning` contains an `if (writeConfig)` branch in its Arrange block. The reviewer checklist flags conditional logic in test bodies. - -The theory mixes two scenarios: no `config.json`, and a `config.json` with or without the setting. Split them into two tests, or pass the config content (or `null`) as theory data and write it through a helper that accepts `null`. - -```csharp -[Fact] -[Trait("Requirement", "FP01-AC10")] -public async Task ReadJsonAsync_OlderVersionWithoutConfigFile_ReturnsMigratedDocumentWithWarning() { /* no config.json */ } - -[Theory] -[Trait("Requirement", "FP01-AC10")] -[InlineData("migrate-and-warn")] -[InlineData(null)] -public async Task ReadJsonAsync_OlderVersionWithMigrateAndWarnOrNoSetting_ReturnsMigratedDocumentWithWarning( - string? outdatedFormatOnRead) { /* always writes config.json */ } -``` - -## Tool Output Appendix - -### dotnet build -- Skipped (build=no, provided). The calling workflow reported a green build in Phase 4. For ad-hoc verification of the Program.cs findings only, `dotnet build src/CreativeCoders.ShadeKit.Cli` was run once: 0 errors. - -### dotnet test -- Skipped (test=no, provided). The calling workflow reported 390/390 tests passing in Phase 4; not re-run here. - -### dotnet format -- Skipped (format=no, provided). diff --git a/docs/reviews/2026-10-18-feature-cli-init-repo-uncommitted.md b/docs/reviews/2026-10-18-feature-cli-init-repo-uncommitted.md new file mode 100644 index 0000000..f030a52 --- /dev/null +++ b/docs/reviews/2026-10-18-feature-cli-init-repo-uncommitted.md @@ -0,0 +1,249 @@ +# .NET Code Review — feature/cli-init-repo (uncommitted) + +**Date:** 2026-10-18 +**Mode:** uncommitted (provided) — working tree vs. HEAD, including staged `git mv` renames and untracked files +**Detected SDK:** 10.0.402 +**Target Framework(s):** net10.0 +**Version origin:** repo:src/CreativeCoders.ShadeKit.Core/CreativeCoders.ShadeKit.Core.csproj (all five project files target net10.0; SDK pinned in global.json) +**Checklist:** review-checklist-net10.md (+ security, performance, architecture, code-quality) +**Tools run:** build=N · format=N · test=N (all provided as `no`; the caller reports build and full test suite green: 0 warnings, 757/757 tests) +**Report language:** English (provided) +**Exclusions:** .gitignore, *.min.js, wwwroot/lib/** +**Review strategy:** chunked (auto-selected, non-interactive; large-diff gate triggered: 98 files > 50, 7650 LOC > 2000) +**Diff size:** 98 files, 7650 changed LOC (incl. Markdown docs, skills, and templates; the C# part is about 45 files) +**Review context:** requirement `docs/feature-packages/02-repository-initialisation.md`; the user's binding decisions from the FP02 implementation brief. Decided behaviour (no rollback on IO failures, removal of exit 11, missing `features/` counts as empty, template sources in `Core/Templates/Resources`, replace-between-markers, trimmed-line rules) is not reported as a defect; risks within those decisions are labelled as such. + +## Executive Summary + +| Severity | Count | +|---|---| +| Critical | 0 | +| Major | 0 | +| Minor | 1 | +| Suggestion | 5 | +| Nitpick | 5 | + +**Top risks:** +1. `TextFileContent` detects only a UTF-8 BOM. `init` appends UTF-8 bytes to a UTF-16 `.gitignore`, `CLAUDE.md`, or `AGENTS.md` and reports success, which leaves a file with mixed encodings. Verified by running the built CLI against a UTF-16LE `.gitignore`. A UTF-16LE `.gitignore` is what Windows PowerShell 5.1 `>` produces. +2. A `CLAUDE.md`, `AGENTS.md`, or `.gitignore` that exists as a directory passes the preconditions, so `init` fails only after `.shadekit/` is written. Re-running `init` then stops with exit 13. Partial writes after a failure are a decided behaviour, but this failure is predictable and could be caught before the first write. +3. A cancellation after the preconditions leaves an empty `.shadekit/` behind, which also blocks the next `init` with exit 13. A test codifies this behaviour and depends on the order of internal calls. + +**Overall:** A carefully built change. Byte-level file handling keeps every byte outside the changed part, CRLF and the BOM are preserved, and the result is idempotent. The order "validate, then write" holds for every precondition the requirement names. The agent contract (JSON envelope, exit codes 12, 13, and 23, `CliErrorMapper`-only mapping) is followed and covered by unit and end-to-end tests, and every FP02 acceptance criterion has a test with a matching trait. The findings are edge cases and cleanups; nothing blocks the merge. + +**Verified facts behind the findings:** +- **FACT:** With a UTF-16LE `.gitignore` (`FF FE 62 00 69 00 6E 00 2F 00 0D 00 0A 00`), `shade-kit init --json` returns `ok: true` and `.gitignore: appended`. The file then continues with plain UTF-8 bytes `0A 0A 23 20 53 48 41 44 45 …` (`# SHADE kit: …`). This was reproduced with `src/CreativeCoders.ShadeKit.Cli/bin/Debug/net10.0/shade-kit.dll` in a scratch repository. +- **FACT:** With `CLAUDE.md` = `# Proj\n\n`, the result starts with `# Proj\n\n\n"; + public const string Text = BeginMarkerPrefix + " - managed by shade-kit, …" + EndMarker; +} + +/// Defines the .gitignore entries for the local-only files of SHADE kit. +public static class KitGitIgnoreEntries +{ + public static IReadOnlyList All { get; } = [".shadekit/local.json", ".shadekit/features/current-feature.json"]; +} +``` + +## Findings — tests/CreativeCoders.ShadeKit.Core.Tests/Init/RepositoryInitializerTests.cs + +### [Suggestion][Tests] tests/CreativeCoders.ShadeKit.Core.Tests/Init/RepositoryInitializerTests.cs:333 +A common setup, `AGENTS.md` as a symlink to `CLAUDE.md`, has no test. It works today: one section, the second file not listed, the symlink kept (verified manually). But this depends on `ApplyAsync` re-reading the file after the first write. A future change, such as reading both files once during validation and reusing that content, would silently write the section twice. Add a regression test and skip it where symlinks cannot be created, for example on Windows without the privilege. + +```csharp +[Fact] +[Trait("Requirement", "FP02-AC5")] +public async Task InitializeAsync_AgentsMdIsSymlinkToClaudeMd_WritesExactlyOneSectionAndKeepsSymlink() +{ + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.WriteFile("# Project\n", ClaudeMd); + var agentsPath = Path.Combine(tempDirectory.RootPath, AgentsMd); + File.CreateSymbolicLink(agentsPath, ClaudeMd); + + // Act + var result = await CreateInitializer().InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + Regex.Count(tempDirectory.ReadFileText(ClaudeMd), " refine refine -- "approval" --> map map --> dor @@ -348,6 +409,7 @@ flowchart TB review -- "no blockers, approval" --> verify verify --> dod dod -- "approval" --> finish + verify -. "failures, reopen" .-> impl classDef checklist stroke-width:2.5px classDef side stroke-dasharray:5 4 @@ -369,7 +431,7 @@ flowchart LR end subgraph quick["quick (v1)"] direction TB - q1[new] --> q2["implement
all in one"] --> q3["finish
single approval"] + q1[new] --> q2["quick
clarify, plan, implement"] --> q3["finish
single approval"] end subgraph refactor["refactor (later)"] direction TB @@ -391,7 +453,7 @@ stateDiagram-v2 [*] --> pending pending --> in_progress: phase start, entry gate approved in_progress --> awaiting_approval: phase work done, next phase requested - awaiting_approval --> in_progress: gate failed or changes requested + awaiting_approval --> in_progress: phase reject (gate failed or changes requested) awaiting_approval --> approved: human approves the transition approved --> in_progress: reopened (another refine round, review findings) approved --> [*] @@ -418,10 +480,13 @@ sequenceDiagram Dev->>Agent: gh-142-login-sso Agent->>CLI: feature set gh-142-login-sso end - CLI-->>Agent: feature, profile, phase, status + CLI-->>Agent: feature, profile, status, phase, phase status + Agent->>Dev: possible next steps (shade-kit-next-phase) + Dev->>Agent: plan + Note over Agent: shade-kit-plan runs its entry protocol Agent->>CLI: phase start plan --json CLI->>Store: read profile and gates.json - CLI-->>Agent: transition allowed, checklist checks/dor.md, skills to load + CLI-->>Agent: transition allowed, checklist checks/definition-of-ready.md, skills to load opt checklist is switched on Agent->>Agent: check DoR against spec.md end @@ -433,6 +498,8 @@ sequenceDiagram Agent->>Agent: load skills and run plan else rejected or gaps found Dev->>Agent: no, fix the gaps first - Agent->>Agent: stay in refine + Agent->>CLI: phase reject plan --note "gaps" + CLI->>Store: map back to in-progress, reject in history + Agent->>Agent: hand off to shade-kit-next-phase, continue in map end ``` diff --git a/research/shade-kit-open-findings.md b/research/shade-kit-open-findings.md new file mode 100644 index 0000000..d112e3d --- /dev/null +++ b/research/shade-kit-open-findings.md @@ -0,0 +1,141 @@ +# SHADE kit open consistency findings + +Status: 2026-10-18, all findings fixed in the clarification on 2026-10-18 (marked "fixed" below). Findings from the consistency review of `README.md`, [shade-kit-concept.md](shade-kit-concept.md), [shade-kit-skills.md](shade-kit-skills.md), the [feature packages](../docs/feature-packages/README.md), and the skill skeletons that are not resolved yet. Design notes only; nothing here is shipped. + +The numbers are the ones from the review, so they can be traced. Resolved on 2026-10-18 and therefore not listed: 1 (entry protocol re-entry and rejection), 2 (router), 3 (ADR 0001 moved into concept §9), 4 (`quick` phase), 5 (`feature drop` confirmation), 11 (`status` in `feature current`), 13 (seven research agents), 20 (`feature` command group), 21 (**Implemented** label), 22 (package 19), 23 (README), 26 (version handshake in the entry protocol), 28 (approval off with a checklist only warns). + +## Contradictions between packages + +### 6. The short `bugfix` plan has no test strategy — fixed + +- **Finding:** [16](../docs/feature-packages/16-bugfix-profile.md) line 45 limits the short `plan.md` to "the chosen fix option and why, risks". The test strategy with the quality commands is missing. FP12-AC6, [13](../docs/feature-packages/13-implement.md) ("Quality commands"), [14](../docs/feature-packages/14-verify.md) (coverage command from `plan.md`), and DOD-01 in [05](../docs/feature-packages/05-gates-and-checklists.md) all take the commands from it. +- **Proposal:** the short plan keeps Summary, Test strategy (with commands), and Risks; every other section says "n/a in bugfix". +- **Fixed (2026-10-18):** Short `plan.md` in `bugfix` fills Summary (fix option and why), Test strategy with commands, Risks, and Deviations; every other section says "n/a in bugfix". Deviations stays because `implement` and DOD-07 use it. Changed: 16 (+FP16-AC8), `shade-kit-plan`, `plan.template.md`. + +### 7. DoD in the `quick` profile — fixed + +- **Finding:** FP17-AC3 marks only the playbook item `n/a`. [05](../docs/feature-packages/05-gates-and-checklists.md) line 125 also lists DOD-04 (`tasks.md`) and DOD-07 (`plan.md`) as `n/a` in `quick`. DOD-01 refers to "the quality commands from `plan.md`". By the `n/a` rule it would be `n/a` in `quick` as well, so build, test, and lint would never be gated. The self-check of the `quick` phase (package 17) does not mention coverage, which DOD-02 requires. +- **Proposal:** change DOD-01 to "the approved quality commands (`plan.md`, or `review.md` where the profile has no plan)". Change FP17-AC3 to "DOD-04, DOD-07, and DOD-09 are `n/a`". Add the coverage measurement to the self-check in package 17. +- **Fixed (2026-10-18):** DOD-01 takes the commands recorded in `review.md`. In `quick`, DOD-04 and DOD-07 are `n/a`; `quick` writes `playbook.md` only for manual criteria, so DOD-09 is `n/a` only without a playbook. The self-check measures coverage. Changed: 05, 17, DoD template, `shade-kit-quick`. + +### 8. DOR-10 and DOR-09 in the `bugfix` profile — fixed + +- **Finding:** + - DOR-10: [05](../docs/feature-packages/05-gates-and-checklists.md) line 125 calls it `n/a` in `bugfix`. [16](../docs/feature-packages/16-bugfix-profile.md) line 40 says it passes on the "affected code" section of `diagnosis.md`, and FP16-AC3 calls it `n/a` again. `n/a` means "not checked", a pass means "checked with evidence". + - DOR-09: it requires the "Security considerations" section, but `diagnose` has no security research and fills that section nowhere. +- **Proposal:** in `bugfix`, DOR-10 is a pass with `diagnosis.md` as evidence; adjust package 05 and FP16-AC3. Add a `diagnose` step that fills "Security considerations". +- **Fixed (2026-10-18):** DOR-10 reworded (`map` done, skipped on purpose, or `diagnosis.md` lists the affected code); passes in `bugfix`. `diagnose` runs the code-level security research into `diagnose / security`, which fills "Security considerations". Changed: 05, 16 (+FP16-AC7), DoR and spec templates, `shade-kit-diagnose`, `shade-kit-plan`, concept §3.3. + +### 9. Report skill, review skill, interview users — fixed + +- **Finding:** + - [06](../docs/feature-packages/06-skill-foundation.md) still lists a general Report skill (line 84), and [15](../docs/feature-packages/15-finish-and-report.md) line 34 writes the report "with the report skill (package 06)". [shade-kit-skills.md](shade-kit-skills.md) keeps the report inline in `shade-kit-finish`. + - The general skill `shade-kit-review` (used by `implement` and `quick`) is missing from package 06. + - Package 06 does not list `quick` as a user of the interview skill. + - The packages README describes package 06 as "shared interview, gate, research, report skills". +- **Proposal:** in package 06, replace Report with Review. Package 06 owns the review skill; package 13 owns the `review.md` format. Add `quick` to the interview users. In package 15 step 3, write the report inline. Update the packages README row. +- **Fixed (2026-10-18):** The report skill stays a general skill but is optional: `report` in `config.json` (`ask` default, `always`, `never`); new skeleton `shade-kit-report`. The review skill is owned completely by package 13 and listed in 06. `quick` added to the interview users, `diagnose` to the research users. Changed: 02, 06, 13, 15, 16, 17, README, `shade-kit-skills.md`, `shade-kit-finish`. + +### 10. No CLI command for the possible next steps — fixed + +- **Finding:** `shade-kit-next-phase` needs the possible next steps (planned `phase next --json`, follow-up 1 in [shade-kit-skills.md](shade-kit-skills.md), open point in package 06). Neither concept §3.2 nor package 04 has the command. Without it, the skill derives the steps from the profile and duplicates engine logic. +- **Proposal:** add `phase next --json` to package 04 and concept §3.2. It returns `continue`, `forward`, `reopen[]`, each with its gate information, matching the table in package 06. +- **Fixed (2026-10-18):** `phase next --json` added to 04 and concept §3.2 with `current`, `continue`, `pending`, `forward`, `reopen`; a `done` feature returns only `current`. Changed: 04 (+FP04-AC14), 06 (+FP06-AC12), `shade-kit-next-phase`, `shade-kit-skills.md`. + +### 12. What is a phase in `shade-kit-skills.md` — fixed + +- **Finding:** [shade-kit-skills.md](shade-kit-skills.md) line 7 says "one skill per phase, including the thin phases `new`, `drop`, and `finish`", and the "Phase skills" table also lists `shade-kit-next-phase`, `shade-kit-discuss`, and `shade-kit-drop`. Concept §5.4 and package 04 say `discuss` and `drop` are not phases. The exception for `shade-kit-new` (no entry protocol) is documented since 2026-10-18; the categories are not. +- **Proposal:** split the skills into phase skills (`new`, `refine`, `map`, `diagnose`, `plan`, `implement`, `quick`, `verify`, `finish`) and workflow skills (`next-phase`, `discuss`, `drop`), and remove `drop` from the list of phases in line 7. +- **Fixed (2026-10-18):** `shade-kit-skills.md` splits phase skills from workflow skills (`next-phase`, `discuss`, `drop`; no entry protocol, only the version handshake); line 7 no longer lists `drop`. + +## Concept not updated + +The packages decided these points, but the concept still says otherwise. Proposal for all of them: confirm or reject the list "Changes and additions to the concept that need confirmation" in the [packages README](../docs/feature-packages/README.md), then update the concept in one pass and empty the list. + +### 14. `gates.json` shape — fixed + +- **Finding:** concept §5.2 (around line 240) keys `gates.json` by phase and switches off approval for `verify`. That also contradicts §5.2 itself (line 227: "the human **always** approves"). [05](../docs/feature-packages/05-gates-and-checklists.md) keys it by profile and needs approval for `verify`. +- **Proposal:** take the shape and defaults from package 05. +- **Fixed (2026-10-18):** Concept §5.2 states the rule (keyed by profile, then target phase; every transition needs approval except into the `quick` phase; missing entry needs approval) and links package 05 instead of an own JSON example. + +### 15. Feature folder tree — fixed + +- **Finding:** the tree in concept §4.1 lacks `discussions/` (in the kit root and in the feature folder), `diagnosis.md`, `verification.md`, and `report.md`. It still lists `data.md` (line 150). `review.md` and `playbook.md` are still "proposed". +- **Proposal:** take the artifact table from the packages README. Remove `data.md`, or keep it as open question 1 without listing it in the tree. +- **Fixed (2026-10-18):** Concept §4.1 tree has `discussions/` (kit root and feature), `diagnosis.md`, `verification.md`, optional `report.md`; `review.md` and `playbook.md` are no longer "proposed". `data.md` stays in the tree as "open; not created in v1"; open question 1 stays. + +### 16. CLI commands and integration content — fixed + +- **Finding:** concept §3.2 lacks `feature show` (package 03), `phase complete` (04), and `phase next` (see 10). Concept §3.1 lists "hooks, MCP config" as integration content; [07](../docs/feature-packages/07-claude-code-integration.md) ships neither in v1. +- **Proposal:** add the three commands to §3.2 and note in §3.1 that hooks and MCP configuration come later. +- **Fixed (2026-10-18):** Concept §3.2 lists `feature show`, `phase complete`, and `phase next`; §3.1 says hooks and MCP configuration come later. + +### 17. Open questions that the packages answered — fixed + +- **Finding:** concept §5.1 still calls the storage of `discuss` output "open", and §10 still lists questions 2 (`discuss` storage) and 4 (switch off approval or only the checklist) as open. Packages 09 and 05 adopted the concept's own proposals. +- **Proposal:** after confirmation, mark both as decided in §5.1 and §10. +- **Fixed (2026-10-18):** Concept §5.1 and §10 questions 2 and 4 are marked **Decided** (numbers kept); README open-questions table updated. + +### 18. Obsolete entry in the packages README — fixed + +- **Finding:** the sequence in concept §11.4 now ends in `phase reject` and "continue in map". Item 10 of the confirmation list in the packages README still describes the old "stay in refine". +- **Proposal:** remove item 10 from the list. +- **Fixed (2026-10-18):** The whole confirmation list (items 1–9) was confirmed and taken over into the concept and `CLAUDE.md`; the list is empty, item 10 removed with it. The matching package labels are **Decided**. + +### 19. No return path from `verify` in the phase flow diagram — fixed + +- **Finding:** concept §11.1 shows no way back from `verify` to `implement`. [14](../docs/feature-packages/14-verify.md) sends a failed verification back to `implement` through a reopen. +- **Proposal:** add a dashed edge `verify -. "failures, reopen" .-> impl` to the diagram. +- **Fixed (2026-10-18):** Concept §11.1 has the dashed edge `verify -. "failures, reopen" .-> impl` and a sentence that any phase can reopen an earlier one with an approval. + +## `CLAUDE.md` + +### 24. v1 scope in `CLAUDE.md` — fixed + +- **Finding:** in `CLAUDE.md` (lines 20–23): + - The phases are "spec → plan → implement → verify → review"; concept §2 asks for them to be replaced. + - "Quality gates … must pass before a phase can complete": in v1 the agent runs the checks and reports, and the CLI only enforces them later (concept §2 and §6). + - "Installs … the kit's skills in a target repository": skills are installed at user level, never in a repository (concept §9.6, package 02). + - The deliverables describe the plugin (`skills/`) as "skills, agents, hooks, and commands"; package 07 ships no hooks in v1. +- **Proposal:** update the v1 scope and the deliverables once the concept is accepted. +- **Fixed (2026-10-18):** `CLAUDE.md` deliverable 1 and the v1 scope describe the three profiles, checklist gates run by the agent, the optional report, and the user-level install; the note in concept §2 is removed. + +## Smaller points + +### 25. `verify` row in `shade-kit-skills.md` — fixed + +- **Finding:** the `shade-kit-verify` row (line 40) names only `playbook.md`, but package 14 and the skeleton also write `verification.md`. +- **Proposal:** add `verification.md` to the row. +- **Fixed (2026-10-18):** The `shade-kit-verify` row names `verification.md` and `playbook.md`. + +### 27. Source of "Decided" labels — fixed + +- **Finding:** since 2026-10-18 the label definition allows "a later interview or review named with its date", and the former ADR decisions are traceable through concept §9. Several **Decided** items in the packages still have no named source, for example the v1 content of `config.json` in package 02. +- **Proposal:** add the source to every **Decided** item that is not in the concept, for example "**Decided** (package interview 2026-10-14)", or downgrade it to **Proposed**. +- **Fixed (2026-10-18):** Every bare **Decided** in the packages now names its source: a concept section, "implemented in code", or the clarification on 2026-10-18. Not covered: the v1 content of `config.json` in 02, downgraded to **Proposed** except the fields decided in concept §9.1–9.2 and `report`. Rule used: a concept statement without a **Proposed**, **Open**, or **Unverified** label counts as decided, because the concept is the interview result. + +## New points from the fixes on 2026-10-18 + +### N1. Two engine rules need confirmation — fixed + +Package 04 got two rules so that the entry protocol has no dead ends. Both are marked **Proposed**: + +- `phase start ` while a transition is pending has the same effect as `phase reject `: the human keeps working on the current phase. +- A repeated `phase start ` while the transition to `` is already pending returns the same gate again and adds no history entry. +- **Fixed (2026-10-18):** `phase start ` while a transition is pending now changes nothing and reports it; the phase skill asks the human and calls `phase reject` only after a yes (**Decided**, FP04-AC13, FP06-AC11, entry protocol of every phase skill, concept §3.2). The repeated `phase start ` rule is **Decided**. + +### N2. Unverified facts in package 19 — fixed + +- What the Cake task `creategithubrelease` writes into the release notes today. This decides how much of FP19-AC6 is already met. +- Whether the branch protection of `main` blocks a merge when the pull request build fails (FP19-AC1). +- **Fixed (2026-10-18):** Checked: `CreateGitHubRelease` (`CreativeCoders.CakeBuild` 6.10.0) takes `ReleaseBody` from `build/BuildContext.cs`, which is the fixed text `SHADE kit Release`, so FP19-AC6 is not met. `main` has no classic branch protection; ruleset 25112099 has a `required_status_checks` rule with an empty list, so a failing PR build does not block the merge (FP19-AC1 not met). Package 19 records both and the step to add `ubuntu-latest`, `windows-latest`, `macos-latest` as required checks. GitHub settings were not changed. + +### N3. Remaining skeleton findings — fixed + +From [shade-kit-skill-skeleton-findings.md](shade-kit-skill-skeleton-findings.md): + +- Finding 3 (no entry protocol in `new`, `discuss`, `drop`) is partly covered by the `shade-kit-new` exception in `shade-kit-skills.md`; the categories are finding 12 above. +- Finding 6 (`phase next`) is finding 10 above. +- Finding 7 (`verification.md`) is finding 25 above. +- Finding 8: every `SKILL.md` has an HTML comment pointing to `research/` and `docs/`. Shipped skills must not depend on `research/` (`CLAUDE.md`), so these comments must go when the skills are worked out. +- **Fixed (2026-10-18):** Skeleton findings 3, 6, 7 resolved through 12, 10, 25; finding 8 through a convention and FP06-AC13 in package 06 (no shipped skill references `research/` or `docs/`; comments go when a skill is worked out). All marked in `shade-kit-skill-skeleton-findings.md`. diff --git a/research/shade-kit-skill-skeleton-findings.md b/research/shade-kit-skill-skeleton-findings.md new file mode 100644 index 0000000..5bb8406 --- /dev/null +++ b/research/shade-kit-skill-skeleton-findings.md @@ -0,0 +1,33 @@ +# SHADE kit skill skeleton findings + +Status: 2026-10-17, updated 2026-10-18 (all findings resolved). Findings from creating the skill skeletons in `skills/` from [shade-kit-skills.md](shade-kit-skills.md). Design notes only; nothing here is shipped. + +## What exists + +One `skills//SKILL.md` per skill with frontmatter (`name`, `description`), purpose, input/output, and flow. No details yet. + +- Phase skills: `shade-kit-new`, `shade-kit-refine`, `shade-kit-map`, `shade-kit-diagnose`, `shade-kit-plan`, `shade-kit-implement`, `shade-kit-quick`, `shade-kit-verify`, `shade-kit-finish`. +- Workflow skills: `shade-kit-next-phase`, `shade-kit-discuss`, `shade-kit-drop`. +- General skills: `shade-kit-interview`, `shade-kit-gate-check`, `shade-kit-research`, `shade-kit-review`, `shade-kit-report` (added 2026-10-18). Each names the input it expects from the calling skill and what it returns. + +## Findings to resolve + +1. **Package 06 contradicts `research/shade-kit-skills.md`.** `docs/feature-packages/06-skill-foundation.md` still describes a router skill, a separate report skill, and a router that runs the gate dialogue. The skeletons follow the newer `shade-kit-skills.md`: no router, report inline in `shade-kit-finish`, entry protocol in every phase skill. Package 06 needs an update. **Resolved 2026-10-18:** package 06 describes `shade-kit-next-phase` and the entry protocol. +2. **Version handshake added to the entry protocol.** The entry protocol in `shade-kit-skills.md` does not list `shade-kit version --json`, but concept §9.6 (formerly ADR 0001) and package 06 require it before every skill. Every skeleton runs it as its first step. **Resolved 2026-10-18:** the entry protocol in `shade-kit-skills.md` lists it as step 1. +3. **No entry protocol in `new`, `discuss`, and `drop`.** `shade-kit-skills.md` says every phase skill runs it, but before `feature new` there is no feature to start a phase for, and `discuss` and `drop` are not phases of any profile (concept §5.4). **Resolved 2026-10-18:** `shade-kit-skills.md` splits phase skills from workflow skills (`next-phase`, `discuss`, `drop`), which run only the version handshake; `shade-kit-new` is the documented exception among the phase skills. +4. **`quick` and `implement` share the `implement` phase.** Both skeletons hand off to each other based on the profile. Package 04 says the engine maps every phase per profile to a skill; if `phase start` returns `shade-kit-quick` for the `quick` profile, the hand-off in the skills is only a safeguard. **Resolved 2026-10-18:** `quick` is a phase of its own (`new` → `quick` → `finish`); the hand-offs are removed. +5. **Most CLI commands do not exist yet.** FACT (checked in `src/`): only `shade-kit version` and `shade-kit features list` (plural group) exist. The concept uses the singular `feature current|set|new|show|drop`. The naming has to be made consistent. Every skeleton notes in a comment that the other commands are planned. **Resolved 2026-10-18:** the group is renamed to `feature` in the code (`feature list`). +6. **`shade-kit phase next --json` is missing** (follow-up 1 in `shade-kit-skills.md`). `shade-kit-next-phase` uses it as planned. **Resolved 2026-10-18:** `phase next --json` is defined in package 04 and concept §3.2. +7. **`verify` also writes `verification.md`.** Package 14 defines it; the table in `shade-kit-skills.md` only names `playbook.md`. **Resolved 2026-10-18:** the row names both. +8. **Source comments point to `research/` and `docs/`.** Each `SKILL.md` has an HTML comment with its source packages as a pointer for the detailed work. Shipped skills must not depend on `research/` (`CLAUDE.md`), so remove these comments when the skills are worked out. **Resolved 2026-10-18:** package 06 has the convention and FP06-AC13 (no shipped skill references `research/` or `docs/`); the comments stay until each skill is worked out. + +## Agent-neutral choices + +- The frontmatter only uses `name` and `description`. The Claude Code field `disable-model-invocation` (user-only phase skills) is left out on purpose; decide when the skills are worked out. +- Subagents are only used where the agent supports them (`shade-kit-research`, `shade-kit-review`, parallel tasks in `shade-kit-implement`), with a sequential fallback. **Unverified:** subagent support in GitHub Copilot and OpenAI Codex CLI (concept open question 5). + +## Next steps + +- Work out each skill in detail; the `skill-creator` skill helps with descriptions and evals. +- Define the research agents that `shade-kit-research` depends on (concept §3.3). +- Create the templates the skills refer to (`discussion.md`, `request.md`, `spec.md`, `plan.md`, `tasks.md`, `review.md`, `verification.md`, `playbook.md`, `report.md`, `researches.md`, checklists). diff --git a/research/shade-kit-skills.md b/research/shade-kit-skills.md new file mode 100644 index 0000000..b227d5d --- /dev/null +++ b/research/shade-kit-skills.md @@ -0,0 +1,73 @@ +# SHADE kit skills + +Status: draft, 2026-10-17, updated 2026-10-18 (consistency review: entry protocol with re-entry and rejection, `quick` phase, seven research agents; findings clarification: phase and workflow skills, optional report skill, pending transition, `phase next`). Result of an interview on [shade-kit-concept.md](shade-kit-concept.md). Lists the skills SHADE kit v1 needs and what each one does roughly. Design notes only; nothing here is shipped. + +## 1. Decisions + +- **One skill per phase**, including the thin phases `new` and `finish`. Each phase can later be replaced on its own through the `replace` extension slot (concept section 7). +- **Phase skills and workflow skills.** Phase skills run a phase of a profile (`new`, `refine`, `map`, `diagnose`, `plan`, `implement`, `quick`, `verify`, `finish`). Workflow skills (`next-phase`, `discuss`, `drop`) belong to no profile; they run no entry protocol, only the version handshake. `discuss` and `drop` are not phases (concept §5.4, package 04). +- **No router skill.** It is replaced by `shade-kit-next-phase`, which finds the current phase, offers the possible next phases, lets the user choose, and starts the chosen phase. +- **Phase skills are profile-aware.** They read the profile from `shade-kit feature current --json` and adjust their behaviour (`bugfix`: short plan, failing test first). +- **`quick` is a phase of its own** (2026-10-18). The `quick` profile is `new` → `quick` → `finish`, and `shade-kit-quick` is the skill of the `quick` phase: a mini run of refine, plan, and implement, not an implement mode. So every phase has exactly one skill named `shade-kit-`. +- **General skills** are separate skills only when more than one phase uses them, or when they are optional. `simplify` stays inline in `shade-kit-implement`. The report is the optional general skill `shade-kit-report`, which `shade-kit-finish` loads only when a report is written (`report` in `config.json`: `ask`, `always`, `never`; package 15). Decided on 2026-10-18. +- **Every phase skill runs the entry protocol itself** as its first step, so a direct call such as `/shade-kit-plan` is gated too. `shade-kit-next-phase` only chooses and hands off. Exception: `shade-kit-new` has no entry protocol, because `shade-kit feature new` creates the feature with `new` already in progress. + +Entry protocol of every phase skill (from concept section 11.4, extended on 2026-10-18): + +1. Version handshake: `shade-kit version --json`. Stop on a mismatch and name what to update. +2. `shade-kit phase start --json`. If the phase is already in progress (new session, another `refine` round), the engine changes nothing and returns no gate: go to step 5. If a transition away from this phase is pending, the engine changes nothing and reports it: ask the human whether to discard it; on yes `shade-kit phase reject --note "resumed "` and go to step 5, on no hand off to `shade-kit-next-phase` and stop. +3. `shade-kit-gate-check` against the returned checklist, if the checklist is switched on. +4. If the gate needs approval: show the result and ask the human. + - Yes: `shade-kit phase approve `. + - No: `shade-kit phase reject --note ""`. The previous phase is in progress again; hand off to `shade-kit-next-phase` and stop. + - If approval is switched off, the engine has already performed the transition; a failed checklist is reported as a warning only. +5. Load the skills the CLI returned and run the phase. + +## 2. Phase skills + +Called by the user, directly or through `shade-kit-next-phase`. Each runs the entry protocol (exception: `shade-kit-new`). + +| Skill | Purpose | +|---|---| +| `shade-kit-new` | Creates a feature with a profile (`feature.json`, `request.md`) from text, an issue URL, a file, or discussion notes. | +| `shade-kit-refine` | Clarifies the requirement through interview and research. Writes `spec.md` (requirement IDs, acceptance criteria, out of scope). Can run several times. | +| `shade-kit-map` | Maps the feature onto the code base with parallel research agents. Writes `researches.md`. | +| `shade-kit-diagnose` | `bugfix` profile: reproduces the bug, finds the root cause, and runs the code-level security research. | +| `shade-kit-plan` | Writes `plan.md` and `tasks.md` with the requirement → test mapping. Short variant for `bugfix`. | +| `shade-kit-implement` | Loops implement → simplify (inline) → review until the review has no blockers. Produces code, tests, and `review.md`. `bugfix`: failing test first. | +| `shade-kit-quick` | `quick` phase (`quick` profile): clarification, plan, and implementation in one run, one approval at the end. | +| `shade-kit-verify` | Runs the quality checks, checks that every acceptance criterion is covered by a passing test, records the results in `verification.md`, and writes `playbook.md` for manual tests. | +| `shade-kit-finish` | Sets the feature status to `done`; loads `shade-kit-report` when a report is written. | + +## 2a. Workflow skills + +Called by the user; outside every profile. No entry protocol, only the version handshake. + +| Skill | Purpose | +|---|---| +| `shade-kit-next-phase` | Finds the current feature and phase, offers the next steps from `shade-kit phase next --json`, lets the user choose, and starts the chosen phase skill. | +| `shade-kit-discuss` | Free brainstorming on any topic. Can run as input for `new` or in any other phase. | +| `shade-kit-drop` | Deletes the feature and clears the current feature. | + +## 3. General skills + +Loaded by the phase skills. + +| Skill | Purpose | Used by | +|---|---|---| +| `shade-kit-interview` | Structured clarification interview. | discuss, refine, diagnose, plan, quick | +| `shade-kit-gate-check` | Checks a checklist (DoR, DoD) against the feature artifacts and reports the result. | Entry protocol of every phase skill | +| `shade-kit-research` | Starts the research agents in parallel and merges their results into `researches.md`. | refine, map, diagnose | +| `shade-kit-review` | Code review with blocker classification (owned by package 13). | implement, quick | +| `shade-kit-report` | Writes `report.md`. Optional: only when a report is written. | finish | + +## 4. Out of scope + +- The seven research agents from concept section 3.3 (security is split into requirement level for `refine` and code level for `map` and `diagnose`). They are subagent definitions, not skills, but `shade-kit-research` depends on them. +- Extensions, memory, and the `refactor` profile (later). +- Skill content, file formats, and CLI details. + +## 5. Follow-ups + +1. ~~`shade-kit-next-phase` needs a CLI command that returns the allowed next phases.~~ Resolved 2026-10-18: `shade-kit phase next --json` (package 04, concept section 3.2). +2. **Unverified:** whether GitHub Copilot and OpenAI Codex CLI support subagents. This decides whether `shade-kit-research` can run its agents in parallel there (concept open question 5). diff --git a/research/version-drift-prior-art.md b/research/version-drift-prior-art.md deleted file mode 100644 index 48b4710..0000000 --- a/research/version-drift-prior-art.md +++ /dev/null @@ -1,546 +0,0 @@ -# Version drift: how Spec Kit, OpenSpec, and gsd-core handle it - -Input for [ADR 0001](../docs/adr/0001-shadekit-version-drift.md). This note compares how three similar tools handle drift between three layers: - -- **CLI**: the installed engine. -- **Agent files**: the skills, commands, agents, and hooks the tool installs for each AI agent. -- **Project data**: the config, state, and template files the tool writes into a repository. - -## Method - -| Tool | Repository | Version | Commit | -|---|---|---|---| -| Spec Kit | `github/spec-kit` | 1.1.4.dev0 | `f75d937` (2026-10-13) | -| OpenSpec | `Fission-AI/OpenSpec` | 1.14.1 | `9111a76` (2026-10-06) | -| gsd-core | `open-gsd/gsd-core` (npm `@opengsd/gsd-core`) | 1.16.0 | `e612933` (2026-10-13) | - -How the analysis was done: - -- Static reading of the source only. Nothing from the repositories was executed. -- Paths are relative to each repository's root at the commit above. -- For gsd-core, the analysis also read a real global install in `~/.claude` (read-only). - -Every claim carries one of these tags: - -| Tag | Meaning | -|---|---| -| **[code]** | Read in the source at the cited lines. | -| **[docs]** | Found only in docs, CHANGELOG, or comments. | -| **[prose]** | gsd-core only: an instruction in a workflow `.md` file that an LLM agent follows. No program enforces it. | -| **[inference]** | A conclusion drawn from verified code that was not observed at runtime. | - -The central claims were re-checked a second time against the source (see [Re-checked claims](#re-checked-claims)). - -## Summary - -None of the three tools versions its project data the way ADR 0001 plans to, that is, with a format version in every file and a hard stop when the version is too new. - -| Tool | How the CLI and the agent files stay in sync | How the CLI and the project data stay in sync | -|---|---|---| -| Spec Kit | Templates ship inside the CLI package. A SHA-256 manifest protects files the user has edited. | Spec Kit writes its version into four files and never reads it back. One integer schema version in `integration.json` gives the only forward-compatibility guard. | -| OpenSpec | Agent files are regenerated from templates on `openspec update` and compared by content. A version stamp is written into each `SKILL.md`. | Project data is barely versioned. Config is parsed leniently, one field at a time. | -| gsd-core | It installs into the agent's config directory with a per-file hash manifest and a framework of installer migrations. Update checks only produce warnings. | Project data has no version. Legacy config keys are rewritten whenever the config is read. | - -| Aspect | Spec Kit | OpenSpec | gsd-core | -|---|---|---|---| -| Where agent files live | in the project (`.claude/skills`, `.github/agents`, …) | in the project (plus one tool in the home directory) | in the agent's config directory (global `~/.claude` or local `/.claude`) | -| Versions recorded | CLI version in 4 project files; `integration_state_schema: 1` | `generatedBy: ` in each `SKILL.md`; none in command files | `VERSION`, a manifest (`manifestVersion: 2` plus a version), install state (`schemaVersion: 1`) for each config directory | -| Is the recorded version ever read? | Only the schema integer | Yes, but compared with `!==` and only in `update` | Only for the update notice and the stale-hook check | -| CLI newer than the agent files | Not detected | Detected in `update` only | Shown as an advisory in the statusline or a banner | -| Agent files or data newer than the CLI | Hard stop only through `integration_state_schema` | Not detected; `update` rewrites the files with the older templates | Not detected | -| Skills check the CLI version at runtime | No | No | No (the identity check matches the package name only) | -| User edits to agent files | SHA-256 manifest; upgrade aborts unless `--force` | No protection; files are overwritten | Hash manifest, backup to `gsd-local-patches/`, three-way merge by the LLM plus a deterministic check | -| Atomic upgrade | No (only for external packages) | No | Partly (install state yes; manifest and `VERSION` no) | -| Per-project version pin | No (`requires.speckit_version` applies to extensions only) | No | No (`engines.gsd` applies to capabilities only) | -| Unknown fields in config/state | Dropped from `integration.json` and manifests | Kept in global config; ignored in `config.yaml` | Kept in `config.json`; dropped from install state | - -## Spec Kit - -### What gets installed and where - -**Packaging.** `pyproject.toml:30-58` force-includes the templates, command templates, scripts, bundled extensions, presets, and workflows into `specify_cli/core_pack/` in the wheel. `init` and `integration upgrade` render the agent files locally from that copy. Per-agent release zips no longer exist: `release.yml` only publishes release notes, and the CHANGELOG records the change as #1803. So the CLI version and the kit version are the same thing. **[code]** (zip history **[docs]**) - -**Agent files.** They belong to the tool and are tracked in `.specify/integrations/.manifest.json`. **[code]** - -- Claude: `.claude/skills/speckit-*/SKILL.md` (`integrations/claude/__init__.py:46-55`) -- Copilot: `.github/agents/*.agent.md` and `.github/prompts/*.prompt.md` -- Codex: `.agents/skills` -- Gemini: `.gemini/commands/*.toml` - -**Shared files.** `.specify/scripts/` and `.specify/templates/` are tracked in `.specify/integrations/speckit.manifest.json` (`shared_infra.py:467-833`). **[code]** - -**State files.** **[code]** - -| File | Contents | Reference | -|---|---|---| -| `.specify/init-options.json` | `ai`, `integration`, `script`, `speckit_version`, … | `command_init.py:850-866` | -| `.specify/integration.json` | `version`, `integration_state_schema`, `installed_integrations`, `default_integration` | `integration_state.py:240-252` | -| `.manifest.json` | `version`, `installed_at`, `files{path: sha256}`, `ownership_modes` (whole/partial/shared), `recovered_files` | `integrations/manifest.py:484-518` | - -**User-owned files.** `.specify/memory/constitution.md` is created only if it is missing and is not tracked (`command_init.py:211-229`). **[code]** - -**Git.** `.specify/.gitignore` excludes only the local parts (`feature.json`, `local-config.yml`, `integrations/packages/`). Everything else in `.specify/` is meant to be committed (`shared_infra.py:24-38`). **[code]** - -### Version records - -The CLI version is written in four places: `init-options.json` (`speckit_version`), `integration.json` (`version`), every `.manifest.json`, and `speckit.manifest.json`. **[code]** - -**None of these values is compared with the running CLI.** **[code]** - -- `speckit_version` is only written (`command_init.py:857`). All other hits for the name are the getter `get_speckit_version()` and the `requires` checks for extensions. -- `manifest.version` is only read to be copied forward (`integrations/_lifecycle.py:502`). -- The `SKILL.md` frontmatter carries no version (`integrations/base.py:1901-1910`). - -### Drift detection - -**Integration state written by a newer Spec Kit.** This is the only guard. **[code]** - -```python -# src/specify_cli/integration_state.py:63-69 -schema = data.get("integration_state_schema") -if (isinstance(schema, int) and not isinstance(schema, bool) - and schema > INTEGRATION_STATE_SCHEMA): - return None, IntegrationReadError(kind="schema_too_new", schema=schema) -``` - -Integration commands then stop with "upgrade Spec Kit" (`integrations/_helpers.py:68-73`). **[code]** - -**CLI newer than the project.** Not detected. `specify self upgrade` does not refresh project files and does not mention them either. **[code]** - -**Recorded CLI version newer than the running CLI.** Not detected. `integration upgrade` re-renders the files with the older bundled templates without any notice. **[inference]**, from `command_upgrade.py:74-87,223-242`. - -**Agent files the user changed.** Detected by hash. **[code]** - -- `IntegrationManifest.check_modified()` compares SHA-256 hashes (`integrations/manifest.py:340-364`). -- Symlinks and unreadable files count as modified. - -**Runtime checks.** No template or script calls `specify version`. The scripts read `integration.json` leniently and fall back to defaults (`scripts/bash/common.sh:252-280`). **[code]** - -**CLI self-update.** Only on request: `specify self check` or `self upgrade` query the GitHub API (`_version.py:107-143`). **[code]** - -### Upgrade - -**`specify integration upgrade [key] [--force]`** (`integrations/command_upgrade.py:30-361`). **[code]** - -1. Loads the old manifest. If any tracked file was modified and `--force` is not set, it exits with code 1 (`:80-87`). -2. Runs a first pass over the shared files. -3. Creates a new manifest stamped with the current CLI version and runs `setup()`, which overwrites the files. -4. Runs a second pass with `refresh_managed` for the default integration only. -5. Removes stale files, that is, files in the old manifest but not in the new one. - -Rollback and limits: - -- If the upgrade fails, it is not rolled back; the command only reports the failure (`:285-291`). -- Journaled rollback exists only when external packages are involved (`_lifecycle.py:468`). -- It handles one integration per call. There is no `--dry-run` and no check that the git tree is clean. - -**`specify init --here [--force]`** is the documented fallback ("escape hatch" in `docs/upgrade.md` **[docs]**). **[code]** - -- With `--force`, it overwrites every shared file with no hash check. -- Even without `--force`, it rewrites the agent files with a fresh manifest and never loads the old one. -- It rewrites `integration.json` with `installed_integrations=[selected]` (`command_init.py:766-780`). Other installed agents drop out of the state while their files stay on disk. **[inference]**, from this line. - -**Config migration.** There are no versioned migration steps. Old shapes are normalized when read, for example the legacy `integration` key becomes `default_integration` and `installed_integrations` (`integration_state.py:166-194`). **[code]** - -**Extension updates.** **[code]** - -- They are transactional: backup and restore in `_command_update_transaction.py:74-626`. -- Compatibility is checked against `requires.speckit_version`. -- An update to a bundled extension is blocked with "upgrade spec-kit" when the catalog version is newer than the copy in the wheel. - -### User edits - -**[code]** - -- Agent files are protected by the hash manifest. Uninstall only removes files whose hash still matches. -- Shared templates and scripts are refreshed only if their hash still matches. Otherwise the CLI prints "Preserved customized…" (`shared_infra.py:498-537`). -- Files that existed before installation and were not written by the tool are recorded as `recovered` and never overwritten automatically (`shared_infra.py:600-617`). -- Stale scripts are deleted only if their hash still matches (`shared_infra.py:770-830`). -- There are no `.new` files, no merge, and no lasting backup. A customized template stays frozen and never receives upstream fixes. - -### Compatibility of config and state - -**[code]** - -- `init-options.json` keeps unknown fields: on write, the file is merged with `{**previous, …}`. -- `integration.json` and the manifests are written with a fixed set of keys (`integration_state.py:240-252`, `integrations/manifest.py:487-499`). **An older CLI therefore drops fields that a newer CLI added.** -- The extension and preset registries store a `schema_version` that is never checked. A corrupt registry is silently reset to empty (`extensions/__init__.py:763-793`), and these writes are not atomic. -- Bundle records accept the same major version and reject everything else as "written by a newer version or is corrupt" (`bundles/records.py:145-166`). - -### Version constraints on components - -**[code]** - -- Extensions, presets, bundles, and external integration packages declare `requires.speckit_version` as a PEP 440 range. It is checked on install and update. -- External integration packages are also checked on every load (`integrations/installer.py:615-628,834`). -- For workflows, `requires` is advisory only (`workflows/engine.py:141-148`). -- Catalogs are fetched from `main`, not from the CLI's release tag (`extensions/__init__.py:4504-4506`). - -### Multiple agents - -**[code]**, consequences **[inference]** - -- Each agent has its own manifest with its own recorded version, and upgrades run one agent per call. Agents can end up on different kit versions without anyone noticing. -- Shared templates are rendered with the command syntax of the **default** agent (`command_upgrade.py:195-218`). - -### Git merges - -**[inference]** - -- The manifests, `integration.json`, and `init-options.json` are committed. Two branches with different kit versions therefore conflict in `version`, `installed_at`, and the hash maps. -- After a manual merge, hashes and file contents can disagree. Files then count as "modified", and the upgrade is blocked until `--force`, which also destroys real user edits. -- The recorded versions differ between branches, but nothing reads them, so nothing warns. - -## OpenSpec - -### What gets installed and where - -**Skills.** `/skills/openspec-*/SKILL.md` (`src/core/shared/skill-paths.ts:23-38`). **[code]** - -- An entire `openspec-*` folder counts as owned by the tool. When the user deselects a workflow, the whole folder is deleted recursively, including files the user added there (`src/core/update.ts:863-888`). -- One tool, `minimax-code`, writes into the home directory instead (`~/.minimax/skills`). - -**Commands.** 35 adapters in `src/core/command-generation/adapters/`. For Claude, the output is `.claude/commands/opsx/.md` with no version in the frontmatter (`adapters/claude.ts:20-34`). **[code]** - -**Shared skills folders.** When several tools share one skills folder, `skills/.openspec-target` records which tool owns it (`src/core/shared-skill-target.ts:7-12`). **[code]** - -**Project data.** **[code]** - -- `openspec/{specs,changes,changes/archive}`. -- `openspec/config.yaml`: written only if it does not exist yet (`src/core/init.ts:1150-1176`). After that it belongs to the user. Later changes go through `yaml.parseDocument`, which keeps comments. -- Project-local workflow schemas in `openspec/schemas//schema.yaml`. - -**User level.** **[code]** - -- `$XDG_CONFIG_HOME/openspec/config.json` (`src/core/global-config.ts:56-77`). -- `$XDG_DATA_HOME/openspec/` for schema overrides, the store registry, and worksets. - -**Legacy files.** The pre-1.0 marker blocks (``) in `AGENTS.md`, `CLAUDE.md`, and similar files are only detected and removed (`src/core/legacy-cleanup.ts`). **[code]** - -### Version records - -**[code]** - -```ts -// src/core/shared/skill-generation.ts:181-191 -metadata: - author: ${template.metadata?.author || 'openspec'} - version: "${template.metadata?.version || '1.0'}" - generatedBy: "${generatedByVersion}" -``` - -- `generatedBy` holds the CLI version and is written into each `SKILL.md`. -- `metadata.version` is hard-coded to `"1.0"` in all templates, so it tells nothing. -- `compatibility:` is free text (`'Requires openspec CLI.'`). -- Command files, `config.yaml`, and the global `config.json` carry no version. -- Every `schema.yaml` must have an integer `version` (`src/core/artifact-graph/types.ts:49-64`), but nothing ever reads it: `getVersion()` has no callers. -- The store and workset files use `version: z.literal(1)` with `.strict()` (`src/core/store/foundation.ts:151-163`). -- Of the CLI's JSON outputs, only `version --json` (`schemaVersion: 1`) and `validate --json` (`version: '1.0'`) carry a version. - -### Drift detection - -**Only in `openspec update`.** **[code]** - -- `getToolVersionStatus` has exactly one caller, `src/core/update.ts:235`. -- `openspec config profile` additionally warns when the profile has drifted (`src/commands/config.ts:326-342`). -- No other command checks anything, and the skills never check the CLI version. The `onboard` skill checks only that the CLI is installed. - -**Three signals are combined** (`src/core/update.ts:231-268`). **[code]** - -1. **Version stamp.** `generatedBy` is read from the first `SKILL.md` found (`tool-detection.ts:336-347`). The comparison is plain inequality: - - ```ts - // src/core/shared/tool-detection.ts:399-403 - const needsUpdate = - configured && - (generatedByVersion === null || - generatedByVersion !== currentVersion || - commandsDrifted); - ``` - -2. **Profile or delivery drift.** Files are missing for selected workflows, or files exist for deselected ones (`src/core/profile-sync-drift.ts:65-176`). -3. **Content drift.** Each `SKILL.md` and command file is regenerated in memory and compared byte by byte, after line endings (CRLF) and BOM are normalized (`update.ts:608-642`, `tool-detection.ts:159-226`). - -Consequences: - -- The comparison has no direction. `compareVersions` exists (`src/core/version-check.ts:127-149`) but is not used here. **[code]** -- An older CLI therefore overwrites skills generated by a newer CLI with its own older templates, without warning. **[inference]** - -**CLI self-update.** **[code]** - -- Runs in `update` and `version --check`. -- Asks the npm registry for `latest` with a 1.5-second timeout. -- For a global npm install it can upgrade itself, check the new binary's `--version`, and then re-run `update` with the new binary (`src/cli/index.ts:332-373`, `version-check.ts:835-927`). The code comment gives the reason: the running process writes files from its own templates, so upgrading afterwards would leave the old ones in place. -- Skipped in CI, with `DO_NOT_TRACK`, or with `OPENSPEC_NO_UPDATE_CHECK`. - -### Upgrade - -**[code]** - -- `update` regenerates the files from the built-in templates and overwrites them. -- Writes go through a plain `fs.writeFile` and are not atomic (`src/utils/file-system.ts:312-316`). A helper for atomic writes exists (`file-state.ts:110-130`) but is only used for store and workset state. -- There is no backup, no dry-run, and no transaction: tools are processed one after another, failures are collected, and the command throws at the end. -- Tool folders that were renamed upstream are migrated, for example `.windsurf` → `.devin` (with the user's consent). Files whose content differs materially stay where they are (`src/core/migration.ts:44-108`). -- There is no migration of the specs or changes formats. The v0→v1 upgrade consists of legacy cleanup plus a hint to move `project.md` into `config.yaml` by hand. - -### User edits - -**[code]** - -- Skill and command files have no protection. An edit is only reported as the reason for the overwrite (`update.ts:670-674`). -- Copilot cloud files are the exception: they are rewritten only if their content exactly matches a known generated version (`src/core/github-copilot/cloud-agent.ts:304-345`). -- Legacy files are deleted only if they still carry the OpenSpec marker. The marker is checked again with `O_NOFOLLOW` (no symlinks followed) just before deleting (`legacy-cleanup.ts:459-479`). -- `config.yaml` is never overwritten. -- If the global `config.json` cannot be parsed, the CLI uses the defaults and refuses to write the file (`global-config.ts:141-250`). - -### Compatibility of config and schemas - -**[code]** - -- `config.yaml` is validated one field at a time with zod `safeParse`. An invalid field produces a warning and is dropped. Unknown keys at the top level are ignored without a warning (`src/core/project-config.ts:317-453`). -- The global `config.json` is loaded as `{...DEFAULT_CONFIG, ...parsed}` and validated with zod `.passthrough()`, so unknown keys survive a save. -- Workflow schemas are resolved in this order: project, then user, then built-in. The first match wins (`src/core/artifact-graph/resolver.ts:120-172`). A project-local fork replaces the built-in schema with no warning and never picks up upstream changes. - -### Multiple agents - -**[code]** - -- No list of configured tools is stored. Each run rediscovers them from the files on disk (`tool-detection.ts:417-450`). -- The profile and delivery mode come from the user's **global** config, not from the project (`update.ts:178-185`). Two developers with different profiles therefore produce different file sets in the same repository. **[inference]** - -### Git merges - -**[inference]** - -- Each `SKILL.md` contains a `generatedBy:` line. Two branches with different CLI versions therefore conflict in every skill. -- The "from" version that `update` reports comes from the first file found, so in a mixed tree it is arbitrary. -- The content comparison still catches every file that differs. Whoever runs `update` next decides the result with their CLI, which can mean a downgrade. -- The project pins no CLI version, so CI cannot enforce one. - -## gsd-core - -### What gets installed and where - -gsd-core installs into the agent's **config directory**, either the global one (`~/.claude`) or a local one in `/`. The scope is `isGlobal ? 'global' : 'local'` (`bin/install.js:10837`). **[code]** - -**[code]** - -| Artifact | Path | Owner | -|---|---|---| -| Engine, workflows, templates, `bin/gsd-tools.cjs` | `gsd-core/**` | tool | -| Kit version | `gsd-core/VERSION` | tool | -| Skills, commands, agents, hooks, scripts | `skills/gsd-*`, `commands/gsd-*`, `agents/gsd-*`, `hooks/` | tool | -| Hook registrations | `settings.json`, `hooks.json`, `config.toml` (depending on the agent) | shared | -| File manifest | `gsd-file-manifest.json` | tool | -| Install state | `gsd-install-state.json` | tool | -| Migration journal | `gsd-migration-journal/.json` plus `-rollback/` and `-backups/` | tool (the backups hold user bytes) | -| Local patches | `gsd-local-patches/**` plus `backup-meta.json` | user data held by the tool | -| Pristine baselines | `gsd-pristine/**` | tool | -| User-owned | `gsd-core/USER-PROFILE.md` (`src/install-engine.cts:250`) | user | - -Outside the config directories: - -- `~/.cache/gsd/…` holds the update cache, shared by all agents and scopes. -- `~/.gsd/defaults.json`. -- Project data: `.planning/config.json`, `STATE.md`, `ROADMAP.md`. - -gsd-core supports about 15 agents (Claude, Codex, Gemini, Copilot, OpenCode, Cursor, …) (`src/installer-migrations/000-first-time-baseline.cts:18-34`). **[code]** - -### Version records - -**[code]** - -| Record | Contents | Reference | -|---|---|---| -| `gsd-core/VERSION` | `pkg.version` | `bin/install.js:12091` | -| `gsd-file-manifest.json` | `manifestVersion: 2` (the manifest's own schema version, separate from the package version), plus `version`, `mode`, `runtime`, `scope`, and `files{rel: sha256}` | `bin/install.js:10086-10105, 10291` | -| `gsd-install-state.json` | `schemaVersion: 1`, `appliedMigrations[{id, appliedAt, journal, checksum}]` | `src/installer-migrations.cts:343-390` | -| Hook header | `// gsd-hook-version: {{GSD_VERSION}}` | `hooks/gsd-check-update.js:2` | -| `backup-meta.json` | `from_version`, `pristine_hashes`, … | `bin/install.js:10555-10565` | -| `.planning/config.json` | **no version** | `src/config.cts:327-400` | -| `STATE.md` | `gsd_state_version: '1.0'`, a constant that nothing compares | `src/state.cts:3342`, `src/state-transition.cts:2521` | - -Readers (`src/installer-migrations.cts`): - -- Manifest reader (`:284-335`): lenient. A missing `manifestVersion` is read as 1, and a newer value is reported as it is. -- Install-state reader (`:357-371`): strict. Corrupt JSON is a hard error, and any numeric `schemaVersion` is accepted with no upper bound. - -The real install on this machine matches: `manifestVersion: 2`, `version: "1.16.0"`, 951 hashed files, and only the baseline migration recorded in the install state. - -### Drift detection - -**Installed version older than npm `latest`.** Advisory only. **[code]** - -- A SessionStart hook starts a worker in the background (`hooks/gsd-check-update.js`). -- The worker runs `npm view @opengsd/gsd-core version` and writes `update_available: latest && isSemverNewer(latest, installed)` atomically to the cache (`hooks/gsd-check-update-worker.js:158, 177-180`). -- The statusline and the banner show "GSD update available: X → Y" (`hooks/gsd-statusline.js:1065-1082`, `hooks/gsd-update-banner.js:63-79`). Nothing blocks. -- Stale hooks are detected when the installed version is higher than the version in a hook's header (`gsd-check-update-worker.js:117-145`). - -**Skills and tool versions.** Not checked by version. **[code]** - -- The launcher block in each workflow (`gsd-core/workflows/quick.md:42`) looks for `gsd-tools.cjs` in this order: project-local first, then about 20 global home directories, then `PATH`. -- The identity check only tests whether `runtime-identity --raw` starts with `{"packageName":"@opengsd/gsd-core"`. The version, which the same payload contains (`src/runtime-identity.cts:262-266`), is ignored. -- `cli-skew-check` (`src/cli-skew-check.cts:22-41`) is a path heuristic that warns on stderr when a project-local tool exists but a different one is running. -- Workflows never read `VERSION`; only `update.md` does. - -**Project data written by a newer or older GSD.** Not detected. **[code]** - -- Nothing compares `gsd_state_version`, and `config.json` has no version. -- Unknown keys at the top level of the config produce a warning once (`src/config-loader.cts:873-882`). - -### Upgrade - -**`/gsd-update`** is a workflow carried out by the LLM. **[prose]**, `gsd-core/workflows/update.md` - -1. Resolves the target with `gsd-tools update-context --json` (local before global). -2. Fetches the latest version and compares. If they are equal, or the installed version is newer ("dev install"), it stops. -3. Shows the changelog and asks the user to confirm. -4. Backs up files the user added that the manifest does not list (`detect-custom-files`). -5. Runs `npx … gsd-core -- --local|--global` for exactly one agent and one scope. -6. Restores the user's files and points to `--reapply` if local patches exist. - -**Installer order.** `bin/install.js`, `install()` at line 10763. **[code]** - -1. `saveLocalPatches`: files whose hash differs from the old manifest are copied to `gsd-local-patches/`. -2. `runInstallerMigrations` (with `baselineScan`). Open prompts stop the run with a hard error. -3. For each managed directory: `rmSync(destDir, {recursive: true})`, then a fresh copy (`:7968-7971`). -4. Writes `VERSION` (`:12091`), then the manifest (`:12470`). Neither write is atomic. - -**Migration framework.** `src/installer-migrations.cts`. **[code]** - -- **Order:** compiled migration files sorted by filename and filtered by agent and scope (`:530-555`). `introducedIn` plays no part in the ordering. -- **Checksum:** SHA-256 over the migration's metadata and `plan.toString()`. If the checksum of an applied migration changes, the stored value is silently overwritten and the migration does not run again (`:466-528`). -- **Classification by manifest hash:** missing, managed-missing, unknown, managed-pristine, or managed-modified (`:431-446`). A removal of a modified file becomes `backup-and-remove`, and a removal of an unknown file becomes `preserve-user` (`:872-878`). -- **Apply:** writes a journal, takes rollback snapshots and backups (symlinks preserved), and restores in reverse order on failure (`:1004-1178`). Rollback works only inside the running process. -- **State:** written atomically via a temporary file and rename (`:374-390`). -- **Lock:** `gsd-install-migration.lock` with `wx`, reclaimed from dead PIDs, 30-second timeout (`:609-700`). -- **Bookkeeping gap:** migrations without actions are recorded as applied only if no migration in the run produced actions. - -**Project config migration.** **[code]** - -- `normalizeLegacyKeys` (`src/configuration.cts:265-310`) hard-codes five key renames, for example `depth` → `granularity`. -- It runs on **every read** and writes the result back unless `persist: false` (`src/config-loader.cts:691, 771, 838`). -- `gsd-tools migrate-config` also exists as an explicit command. -- There is no numbered chain and no schema version. - -### User edits - -- **Local patches:** `saveLocalPatches` (`bin/install.js:10471-10545`) backs up modified files and stores the pristine hashes. **[code]** -- **Reapply:** a three-way merge done by the LLM (pristine baseline, the user's backup, the new file). **[prose]**, `workflows/reapply-patches.md` -- **Verification:** `gsd-core/bin/verify-reapply-patches.cjs` is deterministic. `--classify` runs before the merge. After the merge it exits with code 1 if any line the user added is missing. **[code]** -- **Files the user added** to managed directories are removed by a direct re-install (because of the `rm -rf`). Only the `/gsd-update` workflow backs them up. **[inference]** - -### Compatibility of config and state - -**[code]** - -- Unknown keys in `config.json` survive on disk: on write-back the whole object is serialized. In memory they are ignored. `config-set` rejects unknown keys. -- Defaults are filled in at read time. `new-project` writes a **full snapshot of the defaults** (`src/config.cts:327-400`), so later default changes never reach those keys. **[inference]** -- `writeInstallState` always writes `schemaVersion: 1` and keeps only `appliedMigrations` and `hostGeneration` (`src/installer-migrations.cts:1139-1143`). **An older installer drops newer fields.** - -### Git merges - -**[inference]** - -- `.planning/` has no version stamp, so there is nothing that could conflict or be checked. -- Rewrites triggered on read (legacy keys) produce conflicting edits to `config.json` when another branch uses an older GSD. -- If the local install (`.claude/**`, manifest, install state) is committed, merging different kit versions leads to false "local patches" and an `appliedMigrations` list that does not match the disk. - -## Comparison by drift axis - -### CLI ↔ agent files - -| | Spec Kit | OpenSpec | gsd-core | -|---|---|---|---| -| Source of truth | templates in the wheel | templates in the npm package | files in the npm package | -| How drift is detected | hash only (did the user change it?), never the version | version stamp `!==` plus content comparison | npm `latest` compared with `VERSION` (advisory) | -| When | `integration upgrade` / `status` | `update` | each session (SessionStart hook) | -| Direction-aware | no | no (`!==`) | yes (`isSemverNewer`), but advisory only | -| Version in each agent file | no | `SKILL.md` only | hook header only | - -**Finding:** in all three tools, skills and commands depend on CLI flags and JSON output that are not versioned. They stay compatible only because both sides come out of the same release. None of the skills checks the CLI version at runtime. - -### CLI ↔ project data - -| | Spec Kit | OpenSpec | gsd-core | -|---|---|---|---| -| Format version in project data | one integer in `integration.json` | none (project) | none | -| Guard against data newer than the CLI | yes, for `integration.json` only | strict `literal(1)` on store state only | no | -| Migration of project data | normalized on read | none (legacy cleanup only) | hard-coded key renames on every read, written back | -| Migration written only on explicit command | yes (no migrations) | — | **no** | - -**Finding:** none of the three migrates project data with a chain of format versions. All three rely on additive changes and lenient reading. In each tool, the central integer version, where it exists, is the only thing that protects against a newer format. - -### User edits - -| Mechanism | Spec Kit | OpenSpec | gsd-core | -|---|---|---|---| -| Hash at install time | yes, per file, with ownership modes | no | yes, per file | -| Behavior on update | abort without `--force`; templates frozen | overwrite | backup plus LLM merge plus deterministic check | -| Files the tool did not write | `recovered` flag, never overwritten | known-content allowlist (Copilot only) | `preserve-user` in migrations | -| `.new` / diff offer | no | no | no (merge instead) | - -## Implications for ADR 0001 - -### Where the ADR goes further than all three - -1. **A format version in every file.** None of the three does this. Two of them use a central version (Spec Kit's `integration_state_schema`, gsd-core's install-state `schemaVersion`), and both have the merge problem the ADR describes. gsd-core has no version for project data at all. No tool uses a version file per directory either; the idea assessed earlier has no precedent. -2. **A hard stop when data is newer than the CLI.** Spec Kit has it for one file. OpenSpec and gsd-core lack it for project data and agent files. OpenSpec even downgrades silently because of the `!==` comparison. -3. **Write only during `upgrade`.** gsd-core does the opposite: it rewrites the config on read, which causes noise in the working tree and merge conflicts. This confirms the ADR's decision. -4. **Skills check `shade-kit version`.** None of the three does this. gsd-core comes closest (an identity check without a version). -5. **Unknown fields are preserved.** Spec Kit (`integration.json`, manifests) and gsd-core (install state) drop them with an older version. This confirms ADR decision 4 and makes it worth a test. - -### Worth adopting - -| Pattern | Source | Where it fits in ShadeKit | -|---|---|---| -| A hash manifest with ownership modes (whole/partial/shared) and a `recovered` flag for files the tool did not write | Spec Kit | `templates.lock.json`: a `recovered` state for templates that existed before `init` | -| Hash-gated deletion of files that are no longer shipped | Spec Kit, gsd-core | `upgrade`: delete only templates the user has not changed | -| Lenient reading of older and newer **manifest** formats with a separate `manifestVersion` | gsd-core | `templates.lock.json` gets its own `schemaVersion` | -| Upgrade the CLI first, then regenerate with the **new** binary | OpenSpec | relevant if `shade-kit upgrade` can also update the engine | -| Refuse to save a config file that cannot be parsed | OpenSpec | `config.json`: do not overwrite with defaults when parsing fails | -| A migration lock with stale-PID reclaim, and atomic state writes | gsd-core | `upgrade`: complements the ADR's temp-file-plus-rename plan | -| A deterministic check after a merge instead of trusting the agent | gsd-core | if a skill helps the user adopt template diffs, verify the result in the CLI | -| A version-range gate for extensions (`requires.speckit_version`, `engines.gsd`) | Spec Kit, gsd-core | only when ShadeKit gets extensions (YAGNI for v1) | -| A CI check that forces a version bump when shipped content changes | Spec Kit (`extension-version-guard.yml`) | CI: a template change without a template version bump fails | - -### Worth avoiding - -| Anti-pattern | Source | Consequence | -|---|---|---| -| Recorded versions that are never read | Spec Kit | false sense of safety, no drift warning | -| Comparing versions with `!=` instead of ordering them | OpenSpec | silent downgrade by an older CLI | -| Fixed key sets on write | Spec Kit, gsd-core | an older CLI destroys newer fields | -| Config migration on read, with write-back | gsd-core | dirty working trees, merge conflicts | -| Writing a full snapshot of the defaults into the project config | gsd-core | new defaults never take effect; relevant to the "sparse config" note in ADR 0001 | -| Upgrades that are not atomic | all three | mixed states after a crash | -| `--force` as the only way past a hash conflict | Spec Kit | real user edits get destroyed; the ADR's `.new` approach avoids this | -| A version stamp in every generated file | OpenSpec | merge conflicts in every file on every version change. A content hash in the lock file is enough. | - -### Open question for the ADR - -**Should the project pin an engine version?** None of the three tools pins an engine version per project (V4 in the ADR); each relies on an advisory check or none at all. The ADR is consistent with that in deferring V4. One gap remains in all three tools and in the ADR: CI has no way to enforce a version. A `shade-kit doctor --check` mode with an exit code would close it cheaply. - -## Re-checked claims - -These claims were checked a second time directly against the source: - -- Spec Kit: - - `integration_state.py:63-69`: the schema-too-new guard. - - `integration_state.py:240-252`: a fixed key set on write. - - `integrations/manifest.py:340-364`: `check_modified` by hash. - - `integrations/command_upgrade.py:80-87`: the upgrade aborts without `--force`. - - `pyproject.toml:30-58`: the core pack ships in the wheel. - - `command_init.py:857`: `speckit_version` is only written. A grep finds no comparison. -- OpenSpec: - - `tool-detection.ts:399-403`: the `!==` comparison. - - `skill-generation.ts:181-191`: the frontmatter with `generatedBy`. - - `getToolVersionStatus`: the only caller is `update.ts:235`. - - `file-system.ts:312-316`: a plain, non-atomic `fs.writeFile`. -- gsd-core: - - `installer-migrations.cts:1138-1143`: always `schemaVersion: 1`. - - `installer-migrations.cts:530-538`: migrations are filtered by agent and scope. - - `gsd-check-update-worker.js:158`: `isSemverNewer`. - - `state.cts:3342`: the constant `gsd_state_version`. - - `quick.md:42`: the identity check matches the package name only. - - `install.js:12091` and `10291`: `VERSION` and the manifest are written with plain `writeFileSync`. diff --git a/skills/shade-kit-diagnose/SKILL.md b/skills/shade-kit-diagnose/SKILL.md new file mode 100644 index 0000000..b293ee3 --- /dev/null +++ b/skills/shade-kit-diagnose/SKILL.md @@ -0,0 +1,32 @@ +--- +name: shade-kit-diagnose +description: SHADE kit phase skill for `diagnose` (bugfix profile). Reproduces the reported bug, finds and proves its root cause, and writes diagnosis.md plus a spec.md with the expected behaviour. Use when the user wants to diagnose, reproduce, or find the root cause of the bug of the current SHADE kit feature. +--- + +# shade-kit-diagnose + + + +## Purpose + +Replaces `refine` and `map` in the `bugfix` profile: the bug is made reproducible and its cause is traced in the code with evidence, so the fix addresses the cause and not the symptom. + +| Input | Output | +|---|---| +| `request.md`, code base | `diagnosis.md`, `researches.md` (section `diagnose / security`), `spec.md` | + +## Flow + +1. **Entry protocol:** + 1. Version handshake: `shade-kit version --json`. Stop on a mismatch and name what to update. + 2. `shade-kit phase start diagnose --json`. Stop if the transition is not allowed and report why. If the phase is already in progress, the CLI returns no gate: go to step 5. If a transition away from this phase is pending, the CLI reports it and changes nothing: ask the human whether to discard it. On a yes, call `shade-kit phase reject --note "resumed diagnose"` and go to step 5; on a no, hand off to `shade-kit-next-phase` and stop. + 3. If the gate has a checklist: load `shade-kit-gate-check` on it. + 4. If the gate needs approval: show the result, ask the human, and only after a yes call `shade-kit phase approve diagnose`. On a no, call `shade-kit phase reject diagnose --note ""`, hand off to `shade-kit-next-phase`, and stop. If approval is switched off, a failed checklist is only a warning. + 5. Load the skills the CLI returned. +2. **Read** `request.md`. Load `shade-kit-interview` for missing facts. +3. **Reproduce:** find a reliable trigger; record steps, environment, observed and expected behaviour. If it cannot be reproduced, stop and ask the human for more information instead of guessing. +4. **Root cause:** trace from the symptom to the cause with file and line evidence. Name the causes that were checked and ruled out. +5. **Security:** load `shade-kit-research` with the focus "security at code level" on the affected code and the fix options: is the bug itself a vulnerability, and does a fix touch input handling, authentication, secrets, or protected data? Results go into the section `diagnose / security` of `researches.md`. +6. **Write `diagnosis.md`:** reproduction, observed versus expected behaviour, root cause with evidence, ruled-out causes, affected code, fix options with a recommendation. +7. **Write `spec.md`** in the normal format: the expected behaviour as `REQ-` requirements, each with at least one acceptance criterion stating that the reproduction no longer shows the bug. Fill "Security considerations" from `diagnose / security`. +8. **Report** the root cause and the recommended fix. Continue with `shade-kit-next-phase`. diff --git a/skills/shade-kit-discuss/SKILL.md b/skills/shade-kit-discuss/SKILL.md new file mode 100644 index 0000000..e93b479 --- /dev/null +++ b/skills/shade-kit-discuss/SKILL.md @@ -0,0 +1,27 @@ +--- +name: shade-kit-discuss +description: SHADE kit free brainstorming on any topic (an idea, a design question, a problem), with or without a current feature. Writes discussion notes that can feed a new feature or a refine round. Use when the user wants to discuss, brainstorm, or think something through in a SHADE kit repository. +--- + +# shade-kit-discuss + + + +## Purpose + +Open brainstorming outside every profile, possible at any time and in any phase. The result is a set of discussion notes. This skill never changes the feature state. + +## Flow + +1. **Version handshake:** `shade-kit version --json`. Stop and tell the user what to update on a mismatch. +2. **Context:** `shade-kit feature current --json` to find out whether a feature is current. No feature is needed. +3. **Discuss:** load `shade-kit-interview` in open mode. Ask, propose alternatives, and challenge assumptions. +4. **Notes:** at the end, or when the user asks, write the notes from the `discussion.md` template in `.shadekit/templates/`: topic, date, participants, context, options, conclusions, open questions. + - No current feature: `.shadekit/discussions/--.md` + - Current feature: `.shadekit/features//discussions/--.md` +5. **Next step:** offer to create a feature from the notes (`shade-kit-new`) or, with a current feature, to continue `shade-kit-refine` with the notes as input. + +## Rules + +- No entry protocol: `discuss` is not a phase of any profile. +- Never write `feature.json` or any other kit JSON file. diff --git a/skills/shade-kit-drop/SKILL.md b/skills/shade-kit-drop/SKILL.md new file mode 100644 index 0000000..d8cd16b --- /dev/null +++ b/skills/shade-kit-drop/SKILL.md @@ -0,0 +1,26 @@ +--- +name: shade-kit-drop +description: SHADE kit skill to drop a feature from any phase. Deletes the feature after explicit confirmation and clears the current feature. Use when the user wants to drop, cancel, abandon, or delete a SHADE kit feature. +--- + +# shade-kit-drop + + + +## Purpose + +Removes a feature that will not be built, from any phase. `drop` is not a phase of any profile, so there is no entry protocol, but nothing is deleted without an explicit yes. + +## Flow + +1. **Version handshake:** `shade-kit version --json`. Stop on a mismatch and name what to update. +2. **Feature:** `shade-kit feature current --json`, or the feature the user named. If none is resolved, list the features (`shade-kit feature list --json`) and ask. +3. **Preview:** `shade-kit feature drop --json` without `--yes`. The CLI deletes nothing and returns what it would delete. +4. **Confirm:** show the feature id, profile, phase, and the preview. Ask for an explicit confirmation. +5. **Drop:** only after a yes, `shade-kit feature drop --yes --json`. The CLI deletes the feature folder and clears the current feature. +6. **Report** what was removed. + +## Rules + +- Never drop a feature without a yes in the conversation. +- Do not touch git branches or commits. diff --git a/skills/shade-kit-finish/SKILL.md b/skills/shade-kit-finish/SKILL.md new file mode 100644 index 0000000..86b9685 --- /dev/null +++ b/skills/shade-kit-finish/SKILL.md @@ -0,0 +1,28 @@ +--- +name: shade-kit-finish +description: SHADE kit phase skill for `finish` (every profile). Checks the Definition of Done at the entry gate, optionally writes the feature report (requirements, tests, checks, approvals) with shade-kit-report, and sets the feature to done. Use when the user wants to finish, close, or complete the current SHADE kit feature. +--- + +# shade-kit-finish + + + +## Purpose + +Closes the feature. The entry gate is the Definition of Done. On request it adds a traceable report: which requirements were built, which tests prove them, which checks passed, and who approved each transition. + +| Input | Output | +|---|---| +| Every artifact of the feature | feature status `done`, `report.md` (optional) | + +## Flow + +1. **Entry protocol:** + 1. Version handshake: `shade-kit version --json`. Stop on a mismatch and name what to update. + 2. `shade-kit phase start finish --json`. Stop if the transition is not allowed and report why. If the phase is already in progress, the CLI returns no gate: go to step 5. If a transition away from this phase is pending, the CLI reports it and changes nothing: ask the human whether to discard it. On a yes, call `shade-kit phase reject --note "resumed finish"` and go to step 5; on a no, hand off to `shade-kit-next-phase` and stop. + 3. If the gate has a checklist (default: Definition of Done): load `shade-kit-gate-check` on it. + 4. If the gate needs approval: show the result, ask the human, and only after a yes call `shade-kit phase approve finish`. On a no, call `shade-kit phase reject finish --note ""`, hand off to `shade-kit-next-phase`, and stop. If approval is switched off, a failed checklist is only a warning. + 5. Load the skills the CLI returned. +2. **Report decision:** the result of `shade-kit phase start finish --json` contains the `report` setting from `config.json`: `always` writes a report, `never` skips it, `ask` (default) asks the human. +3. **Report:** if a report is written, load `shade-kit-report` and show its summary to the human. +4. **Complete:** `shade-kit phase complete`. The feature is `done`; its folder stays in the repository. diff --git a/skills/shade-kit-gate-check/SKILL.md b/skills/shade-kit-gate-check/SKILL.md new file mode 100644 index 0000000..e261a9a --- /dev/null +++ b/skills/shade-kit-gate-check/SKILL.md @@ -0,0 +1,31 @@ +--- +name: shade-kit-gate-check +description: SHADE kit general skill that checks a gate checklist (Definition of Ready, Definition of Done) against the feature artifacts, fills in result and evidence per item, and reports pass or fail. Loaded by the entry protocol of every SHADE kit phase skill; not meant to be called directly. +--- + +# shade-kit-gate-check + + + +## Purpose + +Gives the human a checked, evidenced basis for the approval of a phase transition. This skill only checks and reports; it never approves. + +## Input from the calling skill + +- The path of the checklist copy in the feature folder, as returned by `shade-kit phase start --json` (for example `checks/definition-of-ready.md`). +- The feature's profile. + +## Flow + +1. **Read** the checklist copy and the artifacts its items refer to. +2. **Check** every item (`DOR-nn`, `DOD-nn`, …) and set `Result`: + - `pass` or `fail`, with `Evidence` naming what was checked (IDs, paths, command results) or what is missing. + - `n/a` when the item refers to a phase or artifact the profile does not have, with the reason in `Evidence`. +3. **Log** one line in the "Check runs" section: date and time, overall `pass` or `fail`, failed item IDs. +4. **Return** to the calling skill: overall result and the failed items with their evidence. + +## Rules + +- Overwrite `Result` and `Evidence` on every run; never delete earlier "Check runs" lines. +- Do not change any other artifact and never call `shade-kit phase approve`. diff --git a/skills/shade-kit-implement/SKILL.md b/skills/shade-kit-implement/SKILL.md new file mode 100644 index 0000000..39615eb --- /dev/null +++ b/skills/shade-kit-implement/SKILL.md @@ -0,0 +1,34 @@ +--- +name: shade-kit-implement +description: SHADE kit phase skill for `implement` (feature and bugfix profiles). Works through tasks.md in a loop of implement, simplify, and independent review until the review has no open blockers; produces code, tests, and review.md. Bug fixes start with a failing regression test. Use when the user wants to implement the planned tasks of the current SHADE kit feature. +--- + +# shade-kit-implement + + + +## Purpose + +Builds the plan task by task and does not stop at "it compiles": every change is simplified and reviewed independently before the feature can go to `verify`. + +| Input | Output | +|---|---| +| `plan.md`, `tasks.md`, `spec.md` | Code and tests, updated `tasks.md` and `plan.md` (deviations), `review.md` | + +## Flow + +1. **Entry protocol:** + 1. Version handshake: `shade-kit version --json`. Stop on a mismatch and name what to update. + 2. `shade-kit phase start implement --json`. Stop if the transition is not allowed and report why. If the phase is already in progress, the CLI returns no gate: go to step 5. If a transition away from this phase is pending, the CLI reports it and changes nothing: ask the human whether to discard it. On a yes, call `shade-kit phase reject --note "resumed implement"` and go to step 5; on a no, hand off to `shade-kit-next-phase` and stop. + 3. If the gate has a checklist: load `shade-kit-gate-check` on it. + 4. If the gate needs approval: show the result, ask the human, and only after a yes call `shade-kit phase approve implement`. On a no, call `shade-kit phase reject implement --note ""`, hand off to `shade-kit-next-phase`, and stop. If approval is switched off, a failed checklist is only a warning. + 5. Load the skills the CLI returned. +2. **Implement:** work through the open tasks in order, respecting "Depends on". Tasks marked "Parallel: yes" may run in parallel subagents where the agent supports them. Per task: write code and proving tests, run build and tests with the commands from the test strategy, check off tests and the task. A task that cannot be finished gets a `Not done:` note. Record deviations in `plan.md` and keep the coverage matrix in sync. +3. **Simplify (inline):** go over the changed code and remove duplication, needless complexity, and dead code without changing behaviour. Tests stay green. +4. **Review:** load `shade-kit-review`. Findings go into `review.md`. +5. **Loop:** while the review has open blockers, fix them and repeat from step 2. After three rounds with open blockers, ask the human how to continue (go on, accept a finding, or reopen `plan`). +6. **Report** the result and the commands used (recorded in `review.md`). Continue with `shade-kit-next-phase`. + +## Profile `bugfix` + +Write the regression test first and see it fail for the diagnosed reason before any production code changes. Record the failing run in `review.md`. Then run the loop unchanged. diff --git a/skills/shade-kit-interview/SKILL.md b/skills/shade-kit-interview/SKILL.md new file mode 100644 index 0000000..bdcbf7f --- /dev/null +++ b/skills/shade-kit-interview/SKILL.md @@ -0,0 +1,30 @@ +--- +name: shade-kit-interview +description: SHADE kit general skill for a structured clarification interview, one topic at a time with an own proposal per question. Loaded by the SHADE kit skills discuss, refine, diagnose, plan, and quick; not meant to be called directly. +--- + +# shade-kit-interview + + + +## Purpose + +Gets decisions from the human instead of filling gaps silently. Every phase that needs answers uses the same interview, so questions look and behave the same everywhere. + +## Input from the calling skill + +- Mode: `open` (discuss: explore, propose alternatives, challenge assumptions) or `targeted` (a list of findings or questions to resolve). +- The topics or findings to cover. +- The target artifact where answers are recorded (for example `spec.md`, `plan.md`, discussion notes). + +## Flow + +1. **Order** the topics: blocking questions first. +2. **Ask** one topic at a time. Give context, the agent's own proposal, and the alternatives. Use the agent's structured question tool where one exists, plain questions otherwise. +3. **Challenge** answers that contradict earlier answers or the artifacts, once, with the reason. +4. **Record** every answer in the target artifact as the calling skill specifies (for example a `Q-` question moving to "Decisions" with who and when). +5. **Return** to the calling skill: answered topics, topics still open. + +## Rules + +- Never record an assumption as a decision without the human's answer. diff --git a/skills/shade-kit-map/SKILL.md b/skills/shade-kit-map/SKILL.md new file mode 100644 index 0000000..a4d2c94 --- /dev/null +++ b/skills/shade-kit-map/SKILL.md @@ -0,0 +1,32 @@ +--- +name: shade-kit-map +description: SHADE kit phase skill for `map` (feature profile). Maps the current feature onto the code base with parallel research agents (best practice, existing architecture, affected code, don't hand-roll, security) and writes researches.md. Use when the user wants to map, analyse, or research the code base for the current SHADE kit feature. +--- + +# shade-kit-map + + + +## Purpose + +Finds out where and how the specified feature fits into the existing code, so the plan builds on facts instead of guesses. + +| Input | Output | +|---|---| +| `spec.md`, `refine` sections of `researches.md`, code base | `researches.md` (sections `map / `) | + +## Flow + +1. **Entry protocol:** + 1. Version handshake: `shade-kit version --json`. Stop on a mismatch and name what to update. + 2. `shade-kit phase start map --json`. Stop if the transition is not allowed and report why. If the phase is already in progress, the CLI returns no gate: go to step 5. If a transition away from this phase is pending, the CLI reports it and changes nothing: ask the human whether to discard it. On a yes, call `shade-kit phase reject --note "resumed map"` and go to step 5; on a no, hand off to `shade-kit-next-phase` and stop. + 3. If the gate has a checklist: load `shade-kit-gate-check` on it. + 4. If the gate needs approval: show the result, ask the human, and only after a yes call `shade-kit phase approve map`. On a no, call `shade-kit phase reject map --note ""`, hand off to `shade-kit-next-phase`, and stop. If approval is switched off, a failed checklist is only a warning. + 5. Load the skills the CLI returned. +2. **Read** `spec.md` and the `refine` sections of `researches.md`. +3. **Research:** load `shade-kit-research` with five focuses: best practice, existing architecture, existing affected code, don't hand-roll, security at code level. Every finding names its source (file and line, or link); a finding without a source is marked as an assumption. +4. **Summarise** for the human: affected areas, risks, and recommendations for the plan. + +## Skipping the work + +The human can skip the mapping on purpose (for example a change confined to one file). Then write a `map / skipped` section with the reason and the git user into `researches.md`. The phase itself is still passed, so the decision stays visible in the history. diff --git a/skills/shade-kit-new/SKILL.md b/skills/shade-kit-new/SKILL.md new file mode 100644 index 0000000..69ed3f0 --- /dev/null +++ b/skills/shade-kit-new/SKILL.md @@ -0,0 +1,27 @@ +--- +name: shade-kit-new +description: SHADE kit phase skill for `new`. Creates a feature with a workflow profile (feature, bugfix, quick) from free text, an issue URL, a Markdown file, or discussion notes, and writes request.md. Use when the user wants to start a new SHADE kit feature, bug fix, or quick change. +--- + +# shade-kit-new + + + +## Purpose + +Creates the feature folder (`feature.json`, `request.md`) so the work has lasting state. `new` is the first phase of every profile; `shade-kit feature new` sets it to `in-progress`, so there is no gate before it. + +| Input | Output | +|---|---| +| Free text, issue URL, Markdown file, or discussion notes | `feature.json` (through the CLI), `request.md` | + +## Flow + +1. **Version handshake:** `shade-kit version --json`. Stop and tell the user what to update on a mismatch. +2. **Input:** collect the requirement source. +3. **Profile:** if the user did not name one, propose one from the input (defect report → `bugfix`, small and clear change → `quick`, otherwise `feature`) and let the user confirm. +4. **Issue URL:** read the issue with the agent's own tools (for example `gh`); the engine does not call trackers. Use its title for the feature id. +5. **Create:** `shade-kit feature new --profile

--source [--title ] --json`. +6. **`request.md`:** fill it from the template: source kind and reference, the original input unchanged, date, creator. Do not rewrite or summarise the input. +7. **Branch:** offer to create and switch to `feature/`. Run the git command only after a yes. +8. **Report:** the feature id and the next phase of the profile (`refine`, `diagnose`, or `quick`). Offer `shade-kit-next-phase` to continue. diff --git a/skills/shade-kit-next-phase/SKILL.md b/skills/shade-kit-next-phase/SKILL.md new file mode 100644 index 0000000..7603c58 --- /dev/null +++ b/skills/shade-kit-next-phase/SKILL.md @@ -0,0 +1,32 @@ +--- +name: shade-kit-next-phase +description: SHADE kit entry point. Finds the current feature and its phase, offers the phases that may come next, lets the user choose, and starts the chosen SHADE kit phase skill. Use when the user says "continue", "next phase", "what's next", or asks where a SHADE kit feature stands. +--- + +# shade-kit-next-phase + + + +## Purpose + +Moves a feature forward without the user having to know its state. This skill only chooses and hands off. It never runs a gate check and never approves a transition: the chosen phase skill runs the entry protocol itself. + +## Flow + +1. **Version handshake:** `shade-kit version --json`. Stop and tell the user what to update if the engine and kit versions do not match. +2. **Current feature:** `shade-kit feature current --json`. + - Not resolved: list the features (`shade-kit feature list --json`), ask which one to use or offer to create a new one. Then `shade-kit feature set `, or hand off to `shade-kit-new`. +3. **Next steps:** `shade-kit phase next --json`. It returns the current phase and state and the possible steps, each with its gate and skill: + - Feature `done`: only the status. Report that it is finished and offer to pick another feature. + - `continue` (current phase `in-progress`): continue it. + - `pending` (current phase `awaiting-approval`): repeat the pending transition. + - `forward`: move on to the next phase of the profile. + - `reopen[]`: reopen an earlier phase (always needs approval). +4. **Offer** these steps, and always offer `shade-kit-discuss` and `shade-kit-drop` as well. +5. **Choice:** let the user choose, using the agent's structured question tool where one exists. +6. **Hand-off:** start the phase skill of the chosen phase (`shade-kit-`). Its entry protocol performs the transition: forward, re-entry, or reopen. + +## Rules + +- Do not derive the next steps from the profile; `phase next` returns them, so the skill does not duplicate engine logic. +- Never call `shade-kit phase approve`. diff --git a/skills/shade-kit-plan/SKILL.md b/skills/shade-kit-plan/SKILL.md new file mode 100644 index 0000000..27ab7b1 --- /dev/null +++ b/skills/shade-kit-plan/SKILL.md @@ -0,0 +1,37 @@ +--- +name: shade-kit-plan +description: SHADE kit phase skill for `plan` (feature and bugfix profiles). Writes plan.md with design decisions and test strategy and tasks.md with the requirement-to-test mapping and coverage matrix; short variant for bug fixes. Use when the user wants to plan the implementation of the current SHADE kit feature. +--- + +# shade-kit-plan + + + +## Purpose + +Decides how the spec is built and why, and binds every acceptance criterion to the test that will prove it. The entry gate is the Definition of Ready. + +| Input | Output | +|---|---| +| `spec.md`, `researches.md` (`bugfix`: `diagnosis.md` and the `diagnose / security` section) | `plan.md`, `tasks.md` | + +## Flow + +1. **Entry protocol:** + 1. Version handshake: `shade-kit version --json`. Stop on a mismatch and name what to update. + 2. `shade-kit phase start plan --json`. Stop if the transition is not allowed and report why. If the phase is already in progress, the CLI returns no gate: go to step 5. If a transition away from this phase is pending, the CLI reports it and changes nothing: ask the human whether to discard it. On a yes, call `shade-kit phase reject --note "resumed plan"` and go to step 5; on a no, hand off to `shade-kit-next-phase` and stop. + 3. If the gate has a checklist (default: Definition of Ready): load `shade-kit-gate-check` on it. + 4. If the gate needs approval: show the result, ask the human, and only after a yes call `shade-kit phase approve plan`. On a no, call `shade-kit phase reject plan --note ""`, hand off to `shade-kit-next-phase`, and stop. If approval is switched off, a failed checklist is only a warning. + 5. Load the skills the CLI returned. +2. **Read** `spec.md` and `researches.md`. +3. **Clarify** open design choices with `shade-kit-interview`, each with an own recommendation. +4. **Write `plan.md`** from the template: summary, technical context, approach, affected components, design decisions (`D-` IDs), test strategy with the quality commands (build, test, lint, coverage), migration and rollout, risks, deviations ("None." while planning). +5. **Write `tasks.md`** from the template: tasks (`T-` IDs) in work order with requirements, dependencies, parallelism, affected paths, and proving tests, plus the coverage matrix. +6. **Check the mapping** and report gaps: every active requirement is in a task; every acceptance criterion has a planned test or is marked manual with a reason. +7. **Report** the plan summary and the quality commands the human approves with the plan. Continue with `shade-kit-next-phase`. + +## Profile `bugfix` + +- Input is `spec.md`, `diagnosis.md`, and the `diagnose / security` section of `researches.md`. +- Short `plan.md`: fill Summary (the chosen fix option and why, linking `diagnosis.md`), Test strategy (with the quality commands for build, test, lint, and coverage), Risks, and Deviations ("None."); every other section says "n/a in bugfix". Usually one to a few tasks. +- The first task is always the regression test that reproduces the bug and fails before the fix. diff --git a/skills/shade-kit-quick/SKILL.md b/skills/shade-kit-quick/SKILL.md new file mode 100644 index 0000000..ada4614 --- /dev/null +++ b/skills/shade-kit-quick/SKILL.md @@ -0,0 +1,33 @@ +--- +name: shade-kit-quick +description: SHADE kit phase skill for the `quick` phase (quick profile). Runs short clarification, a compact spec, implementation with tests, simplify and review, and a self-check in one go, with a single approval at the end. Use when the current SHADE kit feature has the quick profile and the user wants to implement it. +--- + +# shade-kit-quick + + + +## Purpose + +Runs the `quick` phase of the `quick` profile (`new` → `quick` → `finish`): a mini run of refine, plan, and implement for small, clear changes. It still leaves enough traces (`spec.md`, `review.md`, `verification.md`) for the Definition of Done and the report. + +| Input | Output | +|---|---| +| `request.md` | `spec.md` (compact), code and tests, `review.md`, `verification.md`, `playbook.md` (only for manual criteria) | + +## Flow + +1. **Entry protocol:** + 1. Version handshake: `shade-kit version --json`. Stop on a mismatch and name what to update. + 2. `shade-kit phase start quick --json`. Stop if the transition is not allowed and report why. If the phase is already in progress, the CLI returns no gate: go to step 5. If a transition away from this phase is pending, the CLI reports it and changes nothing: ask the human whether to discard it. On a yes, call `shade-kit phase reject --note "resumed quick"` and go to step 5; on a no, hand off to `shade-kit-next-phase` and stop. By default this gate needs no approval. + 3. If the gate has a checklist: load `shade-kit-gate-check` on it. + 4. If the gate needs approval: show the result, ask the human, and only after a yes call `shade-kit phase approve quick`. On a no, call `shade-kit phase reject quick --note ""`, hand off to `shade-kit-next-phase`, and stop. If approval is switched off, a failed checklist is only a warning. + 5. Load the skills the CLI returned. +2. **Read** `request.md`. +3. **Short clarification:** load `shade-kit-interview` and ask only the questions that block the change. If the change is not small or not clear, recommend dropping it and creating a `feature` or `bugfix` instead, and wait for the human. +4. **Compact spec:** write `spec.md` with only goal, `REQ-` requirements, and acceptance criteria; the other sections say "not applicable in quick". +5. **Implement and test:** write the code and the tests that prove each acceptance criterion. No `plan.md`, no `tasks.md`. +6. **Simplify and review:** simplify inline, then load `shade-kit-review`; loop until no blocker is open. +7. **Self-check:** run the quality commands for build, test, lint, and coverage (from the repository's agent instructions or build files) and write `verification.md` with the criterion → test mapping, the results, and the coverage value. +8. **Playbook on demand:** if a criterion is marked "(manual)", write `playbook.md` with one unchecked item per manual criterion. Otherwise write no playbook. +9. **Report** the result. The single approval follows at the gate into `finish` (`shade-kit-next-phase`). diff --git a/skills/shade-kit-refine/SKILL.md b/skills/shade-kit-refine/SKILL.md new file mode 100644 index 0000000..749b5ee --- /dev/null +++ b/skills/shade-kit-refine/SKILL.md @@ -0,0 +1,30 @@ +--- +name: shade-kit-refine +description: SHADE kit phase skill for `refine` (feature profile). Clarifies the requirement of the current feature through research and a structured interview and writes spec.md with requirement IDs, acceptance criteria, and out of scope. Can run several times. Use when the user wants to refine, clarify, or specify the current SHADE kit feature. +--- + +# shade-kit-refine + + + +## Purpose + +Turns the raw request into a testable specification: vague statements, gaps, contradictions, and unstated assumptions are found and resolved with the human. + +| Input | Output | +|---|---| +| `request.md`, discussion notes of the feature, existing `spec.md` (later rounds) | `spec.md`, `researches.md` (sections `refine / gaps`, `refine / security`) | + +## Flow + +1. **Entry protocol:** + 1. Version handshake: `shade-kit version --json`. Stop on a mismatch and name what to update. + 2. `shade-kit phase start refine --json`. Stop if the transition is not allowed and report why. If the phase is already in progress, the CLI returns no gate: go to step 5. If a transition away from this phase is pending, the CLI reports it and changes nothing: ask the human whether to discard it. On a yes, call `shade-kit phase reject --note "resumed refine"` and go to step 5; on a no, hand off to `shade-kit-next-phase` and stop. + 3. If the gate has a checklist: load `shade-kit-gate-check` on it. + 4. If the gate needs approval: show the result, ask the human, and only after a yes call `shade-kit phase approve refine`. On a no, call `shade-kit phase reject refine --note ""`, hand off to `shade-kit-next-phase`, and stop. If approval is switched off, a failed checklist is only a warning. + 5. Load the skills the CLI returned. +2. **Read** `request.md`, the feature's discussion notes, and an existing `spec.md`. +3. **Research:** load `shade-kit-research` with the focuses "gaps and open questions" and "security at requirement level". +4. **Interview:** load `shade-kit-interview` on the findings, one topic at a time, each with an own proposal. Put every assumption to the human. +5. **Write `spec.md`** from the template. Answered questions move from "Open questions" to "Decisions"; their inline markers are removed. IDs are never renumbered. +6. **Report** what changed and which questions are still open. The human decides between another round and moving on (`shade-kit-next-phase`). diff --git a/skills/shade-kit-report/SKILL.md b/skills/shade-kit-report/SKILL.md new file mode 100644 index 0000000..e740196 --- /dev/null +++ b/skills/shade-kit-report/SKILL.md @@ -0,0 +1,28 @@ +--- +name: shade-kit-report +description: SHADE kit general skill that writes report.md for a finished feature: requirements, acceptance criteria and proving tests, check results, review findings, manual checks, deviations, and approvals. Optional; loaded by the SHADE kit skill finish only when a report is written; not meant to be called directly. +--- + +# shade-kit-report + + + +## Purpose + +Collects the evidence of a feature in one place, so reviewers of the pull request do not have to gather it from several artifacts. The report is optional: `finish` loads this skill only when the `report` setting (`ask`, `always`, `never`) leads to a report. + +## Input from the calling skill + +- The feature id. + +## Flow + +1. **Read state:** `shade-kit feature show --json` (profile, phases, approval history). +2. **Read artifacts** the profile produced: `spec.md`, `tasks.md`, `review.md`, `verification.md`, `playbook.md`, `checks/definition-of-done.md`, and in `bugfix` `diagnosis.md`. +3. **Write `report.md`** from the template: summary, requirements and tests, checks, review, manual checks, deviations, approvals. A section whose artifact the profile does not have says so in one line. +4. **Return** the report summary to the calling skill. + +## Rules + +- Take every value from the artifacts and the CLI; never fill a section with assumptions. +- Never change the feature state; `finish` calls `shade-kit phase complete`. diff --git a/skills/shade-kit-research/SKILL.md b/skills/shade-kit-research/SKILL.md new file mode 100644 index 0000000..d8545e8 --- /dev/null +++ b/skills/shade-kit-research/SKILL.md @@ -0,0 +1,25 @@ +--- +name: shade-kit-research +description: SHADE kit general skill that runs a set of research focuses, one research agent per focus in parallel where the agent supports subagents, and merges the results into researches.md. Loaded by the SHADE kit skills refine and map; not meant to be called directly. +--- + +# shade-kit-research + + + +## Purpose + +Collects facts with sources before decisions are made, and keeps them in one place (`researches.md`) per feature. + +## Input from the calling skill + +- The calling phase (`refine` or `map`). +- The focuses to run (for example "gaps and open questions", "existing architecture"). +- The feature context: `request.md`, `spec.md`, earlier `researches.md` sections. + +## Flow + +1. **Start** one research agent per focus. Run them in parallel as subagents where the agent supports it (Claude Code), sequentially otherwise. +2. **Collect** the findings. Every finding names its source (file and line, or link); a finding without a source is marked as an assumption. +3. **Merge** into `researches.md`, created from the template on the first run: one section per ` / ` with date, findings, and sources. Replace the section of the same phase and focus; leave all other sections unchanged. +4. **Return** to the calling skill: a short summary per focus. diff --git a/skills/shade-kit-review/SKILL.md b/skills/shade-kit-review/SKILL.md new file mode 100644 index 0000000..bfa27a9 --- /dev/null +++ b/skills/shade-kit-review/SKILL.md @@ -0,0 +1,31 @@ +--- +name: shade-kit-review +description: SHADE kit general skill for an independent code review of the current change against spec.md, plan.md, and the repository's conventions, with findings classified by severity (blocker, major, minor, note) in review.md. Loaded by the SHADE kit skills implement and quick; not meant to be called directly. +--- + +# shade-kit-review + + + +## Purpose + +Decides whether the implementation loop may end: only an open blocker keeps it going. The review runs in a separate context that did not write the code. + +## Input from the calling skill + +- The change to review (working tree or branch diff). +- `spec.md`, `plan.md` (if the profile has one), and the repository's agent instructions. +- The current loop round. + +## Flow + +1. **Start** the review in an independent context: a subagent where the agent supports it. +2. **Review** the change against the spec, the plan, and the conventions: correctness, missing or weak tests, design, security, lowered quality bar. +3. **Classify** each finding: ID `F-`, severity `blocker`, `major`, `minor`, or `note`, file and line, description, status `open`. Re-check findings of earlier rounds and set them to `fixed` where they are. +4. **Write `review.md`** from the template: a "Rounds" entry (date, what changed, check results), the findings, the commands used. +5. **Return** to the calling skill: open blockers and the other findings. + +## Rules + +- Only the human sets a finding to `accepted`, with a reason. +- Never change code; the calling skill fixes the findings. diff --git a/skills/shade-kit-verify/SKILL.md b/skills/shade-kit-verify/SKILL.md new file mode 100644 index 0000000..7206b37 --- /dev/null +++ b/skills/shade-kit-verify/SKILL.md @@ -0,0 +1,35 @@ +--- +name: shade-kit-verify +description: SHADE kit phase skill for `verify` (feature and bugfix profiles). Runs the quality checks, confirms that every acceptance criterion is covered by a passing test, and writes verification.md and a playbook.md for manual tests. Use when the user wants to verify or test the implementation of the current SHADE kit feature. +--- + +# shade-kit-verify + + + +## Purpose + +Proves that the implementation meets the spec: automated checks by the agent, plus a playbook for what a human has to see working. + +| Input | Output | +|---|---| +| Code, `spec.md`, `tasks.md`, `review.md`, `plan.md` | `verification.md`, `playbook.md` | + +## Flow + +1. **Entry protocol:** + 1. Version handshake: `shade-kit version --json`. Stop on a mismatch and name what to update. + 2. `shade-kit phase start verify --json`. Stop if the transition is not allowed and report why. If the phase is already in progress, the CLI returns no gate: go to step 5. If a transition away from this phase is pending, the CLI reports it and changes nothing: ask the human whether to discard it. On a yes, call `shade-kit phase reject --note "resumed verify"` and go to step 5; on a no, hand off to `shade-kit-next-phase` and stop. + 3. If the gate has a checklist: load `shade-kit-gate-check` on it. + 4. If the gate needs approval: show the result, ask the human, and only after a yes call `shade-kit phase approve verify`. On a no, call `shade-kit phase reject verify --note ""`, hand off to `shade-kit-next-phase`, and stop. If approval is switched off, a failed checklist is only a warning. + 5. Load the skills the CLI returned. +2. **Quality checks:** run the build, test, and lint commands recorded in `review.md` and the coverage command from the test strategy in `plan.md`. +3. **Coverage of criteria:** for every acceptance criterion in `spec.md`, find its proving tests in the coverage matrix of `tasks.md` and confirm that they exist and passed in this run. Criteria marked manual go to the playbook. +4. **Write `verification.md`:** date, commands with results, coverage value, table acceptance criterion → test → result. +5. **Write `playbook.md`:** one unchecked item per manual criterion and per behaviour the human should see working (steps, expected result). +6. **Report** the result. If anything failed, recommend reopening `implement` and name the failures. Continue with `shade-kit-next-phase`. + +## Rules + +- Never check off playbook items; only a human does. +- `bugfix`: the regression test is one of the proving tests and must pass. diff --git a/src/CreativeCoders.ShadeKit.Cli/Commands/Feature/FeatureCommandGroup.cs b/src/CreativeCoders.ShadeKit.Cli/Commands/Feature/FeatureCommandGroup.cs new file mode 100644 index 0000000..b3aa9e1 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Cli/Commands/Feature/FeatureCommandGroup.cs @@ -0,0 +1,17 @@ +using CreativeCoders.Cli.Core; +using CreativeCoders.ShadeKit.Cli.Commands.Feature; + +[assembly: CliCommandGroup([FeatureCommandGroup.Name], "Commands for working with SHADE kit features")] + +namespace CreativeCoders.ShadeKit.Cli.Commands.Feature; + +///

+/// Defines the command group that contains the commands for working with SHADE kit features. +/// +public static class FeatureCommandGroup +{ + /// + /// Represents the group name used on the command line. + /// + public const string Name = "feature"; +} diff --git a/src/CreativeCoders.ShadeKit.Cli/Commands/Features/List/FeaturesListCommand.cs b/src/CreativeCoders.ShadeKit.Cli/Commands/Feature/List/FeatureListCommand.cs similarity index 57% rename from src/CreativeCoders.ShadeKit.Cli/Commands/Features/List/FeaturesListCommand.cs rename to src/CreativeCoders.ShadeKit.Cli/Commands/Feature/List/FeatureListCommand.cs index 577ec77..ff21932 100644 --- a/src/CreativeCoders.ShadeKit.Cli/Commands/Features/List/FeaturesListCommand.cs +++ b/src/CreativeCoders.ShadeKit.Cli/Commands/Feature/List/FeatureListCommand.cs @@ -6,7 +6,7 @@ using JetBrains.Annotations; using Spectre.Console; -namespace CreativeCoders.ShadeKit.Cli.Commands.Features.List; +namespace CreativeCoders.ShadeKit.Cli.Commands.Feature.List; /// /// Lists the names of all features in the SHADE kit store of the current repository. @@ -16,16 +16,16 @@ namespace CreativeCoders.ShadeKit.Cli.Commands.Features.List; /// The writer that receives the JSON output. /// The mapper that turns store failures into exit codes, error codes, and messages. [UsedImplicitly] -[CliCommand([FeaturesCommandGroup.Name, Name], Description = "Lists all features of the SHADE kit store")] -public class FeaturesListCommand( +[CliCommand([FeatureCommandGroup.Name, Name], Description = "Lists all features of the SHADE kit store")] +public class FeatureListCommand( IAnsiConsole ansiConsole, IKitStoreFactory kitStoreFactory, ICliJsonWriter jsonWriter, CliErrorMapper errorMapper) - : ICliCommand + : ICliCommand { /// - /// Represents the command name used on the command line within the . + /// Represents the command name used on the command line within the . /// public const string Name = "list"; @@ -43,27 +43,32 @@ public class FeaturesListCommand( /// /// The store is the .shadekit directory at the root of the git repository that contains the current /// directory. Plain output is one name per line, or a hint if the store has no features. JSON output is an - /// envelope whose data is a ; a store without features is a result, - /// not an error. In JSON mode, an expected failure is written as an error envelope and the abort message is not - /// printed. + /// envelope whose data is a ; a store without features, also one + /// without a features directory, is a result, not an error. In JSON mode, an expected failure is written as + /// an error envelope and the abort message is not printed. /// /// The options of the command. - /// A task that represents the asynchronous operation. The task result is . + /// + /// A task that represents the asynchronous operation. The task result is . + /// /// is . /// - /// No SHADE kit root is found (exit code ), or the SHADE kit - /// root directory contains no features directory (exit code - /// ). + /// The current directory is not inside a git repository (exit code + /// ), no SHADE kit root is found (exit code + /// ), or .shadekit at the repository root is a file (exit + /// code ). /// - public async Task ExecuteAsync(FeaturesListCommandOptions options) + public async Task ExecuteAsync(FeatureListCommandOptions options) { Ensure.NotNull(options); - var featureNames = GetFeatureNames(options.Json); + var featureNames = CliCommandAbort.Run( + () => _kitStoreFactory.Create().GetFeatureNames().Order(StringComparer.Ordinal).ToArray(), _errorMapper, + options.Json, _jsonWriter); if (options.Json) { - _jsonWriter.Write(CliJsonEnvelope.Success(new FeaturesListCommandData(featureNames))); + _jsonWriter.Write(CliJsonEnvelope.Success(new FeatureListCommandData(featureNames))); return CommandResult.Success; } @@ -82,28 +87,4 @@ public async Task ExecuteAsync(FeaturesListCommandOptions options return CommandResult.Success; } - - private string[] GetFeatureNames(bool json) - { - try - { - return [.. _kitStoreFactory.Create().GetFeatureNames().Order(StringComparer.Ordinal)]; - } - catch (Exception e) when (_errorMapper.Map(e) is { } error) - { - throw CreateAbortException(error, json, e); - } - } - - private CliCommandAbortException CreateAbortException(CliError error, bool json, Exception exception) - { - if (!json) - { - return new CliCommandAbortException(Markup.Escape(error.Message), error.ExitCode, exception); - } - - _jsonWriter.Write(CliJsonEnvelope.Failure(error.ToJsonMessage())); - - return new CliCommandAbortException(error.Message, error.ExitCode, exception) { PrintMessage = false }; - } } diff --git a/src/CreativeCoders.ShadeKit.Cli/Commands/Feature/List/FeatureListCommandData.cs b/src/CreativeCoders.ShadeKit.Cli/Commands/Feature/List/FeatureListCommandData.cs new file mode 100644 index 0000000..0f80147 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Cli/Commands/Feature/List/FeatureListCommandData.cs @@ -0,0 +1,7 @@ +namespace CreativeCoders.ShadeKit.Cli.Commands.Feature.List; + +/// +/// Represents the data object that shade-kit feature list --json returns. +/// +/// The names of all features in ordinal order; an empty list if the store has none. +public sealed record FeatureListCommandData(IReadOnlyList Features); diff --git a/src/CreativeCoders.ShadeKit.Cli/Commands/Feature/List/FeatureListCommandOptions.cs b/src/CreativeCoders.ShadeKit.Cli/Commands/Feature/List/FeatureListCommandOptions.cs new file mode 100644 index 0000000..15663e9 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Cli/Commands/Feature/List/FeatureListCommandOptions.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CreativeCoders.ShadeKit.Cli.Commands.Feature.List; + +/// +/// Represents the options of the . +/// +[SuppressMessage("Minor Code Smell", "S2094:Classes should not be empty", + Justification = "Every command needs its own options type; this command has no options beyond --json.")] +public class FeatureListCommandOptions : JsonCommandOptions; diff --git a/src/CreativeCoders.ShadeKit.Cli/Commands/Features/FeaturesCommandGroup.cs b/src/CreativeCoders.ShadeKit.Cli/Commands/Features/FeaturesCommandGroup.cs deleted file mode 100644 index e20a3cc..0000000 --- a/src/CreativeCoders.ShadeKit.Cli/Commands/Features/FeaturesCommandGroup.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CreativeCoders.Cli.Core; -using CreativeCoders.ShadeKit.Cli.Commands.Features; - -[assembly: CliCommandGroup([FeaturesCommandGroup.Name], "Commands for working with SHADE kit features")] - -namespace CreativeCoders.ShadeKit.Cli.Commands.Features; - -/// -/// Defines the command group that contains the commands for working with SHADE kit features. -/// -public static class FeaturesCommandGroup -{ - /// - /// Represents the group name used on the command line. - /// - public const string Name = "features"; -} diff --git a/src/CreativeCoders.ShadeKit.Cli/Commands/Features/List/FeaturesListCommandData.cs b/src/CreativeCoders.ShadeKit.Cli/Commands/Features/List/FeaturesListCommandData.cs deleted file mode 100644 index 6e5961e..0000000 --- a/src/CreativeCoders.ShadeKit.Cli/Commands/Features/List/FeaturesListCommandData.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CreativeCoders.ShadeKit.Cli.Commands.Features.List; - -/// -/// Represents the data object that shade-kit features list --json returns. -/// -/// The names of all features in ordinal order; an empty list if the store has none. -public sealed record FeaturesListCommandData(IReadOnlyList Features); diff --git a/src/CreativeCoders.ShadeKit.Cli/Commands/Features/List/FeaturesListCommandOptions.cs b/src/CreativeCoders.ShadeKit.Cli/Commands/Features/List/FeaturesListCommandOptions.cs deleted file mode 100644 index 454b1a4..0000000 --- a/src/CreativeCoders.ShadeKit.Cli/Commands/Features/List/FeaturesListCommandOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CreativeCoders.ShadeKit.Cli.Commands.Features.List; - -/// -/// Represents the options of the . -/// -public class FeaturesListCommandOptions : JsonCommandOptions; diff --git a/src/CreativeCoders.ShadeKit.Cli/Commands/Init/InitCommand.cs b/src/CreativeCoders.ShadeKit.Cli/Commands/Init/InitCommand.cs new file mode 100644 index 0000000..2c95b47 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Cli/Commands/Init/InitCommand.cs @@ -0,0 +1,101 @@ +using CreativeCoders.Cli.Core; +using CreativeCoders.Cli.Hosting.Exceptions; +using CreativeCoders.Core; +using CreativeCoders.ShadeKit.Cli.Output; +using CreativeCoders.ShadeKit.Core.Init; +using CreativeCoders.ShadeKit.Core.Store; +using JetBrains.Annotations; +using Spectre.Console; + +namespace CreativeCoders.ShadeKit.Cli.Commands.Init; + +/// +/// Initialises SHADE kit in the git repository that contains the current directory. +/// +/// The console that receives the plain summary. +/// The initializer that writes the SHADE kit files into the repository. +/// The writer that receives the JSON output. +/// +/// The mapper that turns initialisation failures into exit codes, error codes, and messages. +/// +[UsedImplicitly] +[CliCommand([Name], Description = "Initialises SHADE kit in the current git repository")] +public class InitCommand( + IAnsiConsole ansiConsole, + IRepositoryInitializer repositoryInitializer, + ICliJsonWriter jsonWriter, + CliErrorMapper errorMapper) + : ICliCommand +{ + /// + /// Represents the command name used on the command line. + /// + public const string Name = "init"; + + private readonly IAnsiConsole _ansiConsole = Ensure.NotNull(ansiConsole); + + private readonly IRepositoryInitializer _repositoryInitializer = Ensure.NotNull(repositoryInitializer); + + private readonly ICliJsonWriter _jsonWriter = Ensure.NotNull(jsonWriter); + + private readonly CliErrorMapper _errorMapper = Ensure.NotNull(errorMapper); + + /// + /// Initialises the repository and writes what was created or changed, as plain text or as a JSON envelope. + /// + /// + /// The initialisation works at the root of the git repository that contains the current directory. Plain output is + /// the line Initialised SHADE kit in <root>., one line <action> <path> per file, + /// and a hint to commit the files. JSON output is an envelope whose data is an + /// . In JSON mode, an expected failure is written as an error envelope and the abort + /// message is not printed. + /// + /// The options of the command. + /// A task that represents the asynchronous operation. The task result is . + /// is . + /// + /// The current directory is not inside a git repository (exit code + /// ); the repository is already initialised (exit code + /// ); .shadekit at the repository root is a file + /// (exit code ); .gitignore, CLAUDE.md, or + /// AGENTS.md is a directory (exit code ) or not UTF-8 + /// (exit code ); or the managed section markers in + /// CLAUDE.md or AGENTS.md are invalid (exit code + /// ). + /// + public async Task ExecuteAsync(InitCommandOptions options) + { + Ensure.NotNull(options); + + var result = await CliCommandAbort.RunAsync( + () => _repositoryInitializer.InitializeAsync(Directory.GetCurrentDirectory()), _errorMapper, + options.Json, _jsonWriter) + .ConfigureAwait(false); + var data = InitCommandData.FromResult(result); + + if (options.Json) + { + _jsonWriter.Write(CliJsonEnvelope.Success(data)); + } + else + { + WriteSummary(data); + } + + return CommandResult.Success; + } + + private void WriteSummary(InitCommandData data) + { + _ansiConsole.MarkupLine($"Initialised SHADE kit in {Markup.Escape(data.RepositoryRoot)}."); + + foreach (var file in data.Files) + { + _ansiConsole.MarkupLine( + $"{Markup.Escape(CliJsonWriter.GetEnumName(file.Action))} {Markup.Escape(file.Path)}"); + } + + _ansiConsole.MarkupLine( + $"Commit {Markup.Escape($"'{KitStorePaths.RootPath}/'")} and the changed files together with your code."); + } +} diff --git a/src/CreativeCoders.ShadeKit.Cli/Commands/Init/InitCommandData.cs b/src/CreativeCoders.ShadeKit.Cli/Commands/Init/InitCommandData.cs new file mode 100644 index 0000000..e750176 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Cli/Commands/Init/InitCommandData.cs @@ -0,0 +1,28 @@ +using CreativeCoders.Core; +using CreativeCoders.ShadeKit.Core.Init; + +namespace CreativeCoders.ShadeKit.Cli.Commands.Init; + +/// +/// Represents the data object that shade-kit init --json returns. +/// +/// The full path of the root directory of the initialised git repository. +/// +/// The files and directories that were created or changed, in the order in which they were written. Each action is +/// written as created, appended, or updated. +/// +public sealed record InitCommandData(string RepositoryRoot, IReadOnlyList Files) +{ + /// + /// Creates the data from the result of a repository initialisation. + /// + /// The result of the initialisation. + /// The data with the repository root and the files of . + /// is . + public static InitCommandData FromResult(RepositoryInitResult result) + { + Ensure.NotNull(result); + + return new InitCommandData(result.RepositoryRootPath, result.Files); + } +} diff --git a/src/CreativeCoders.ShadeKit.Cli/Commands/Init/InitCommandOptions.cs b/src/CreativeCoders.ShadeKit.Cli/Commands/Init/InitCommandOptions.cs new file mode 100644 index 0000000..78db8ab --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Cli/Commands/Init/InitCommandOptions.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CreativeCoders.ShadeKit.Cli.Commands.Init; + +/// +/// Represents the options of the . +/// +[SuppressMessage("Minor Code Smell", "S2094:Classes should not be empty", + Justification = "Every command needs its own options type; this command has no options beyond --json.")] +public class InitCommandOptions : JsonCommandOptions; diff --git a/src/CreativeCoders.ShadeKit.Cli/Commands/Version/VersionCommandOptions.cs b/src/CreativeCoders.ShadeKit.Cli/Commands/Version/VersionCommandOptions.cs index 76adf63..3299fb4 100644 --- a/src/CreativeCoders.ShadeKit.Cli/Commands/Version/VersionCommandOptions.cs +++ b/src/CreativeCoders.ShadeKit.Cli/Commands/Version/VersionCommandOptions.cs @@ -1,6 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + namespace CreativeCoders.ShadeKit.Cli.Commands.Version; /// /// Represents the options of the . /// +[SuppressMessage("Minor Code Smell", "S2094:Classes should not be empty", + Justification = "Every command needs its own options type; this command has no options beyond --json.")] public class VersionCommandOptions : JsonCommandOptions; diff --git a/src/CreativeCoders.ShadeKit.Cli/Output/CliCommandAbort.cs b/src/CreativeCoders.ShadeKit.Cli/Output/CliCommandAbort.cs new file mode 100644 index 0000000..d2a95cf --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Cli/Output/CliCommandAbort.cs @@ -0,0 +1,126 @@ +using CreativeCoders.Cli.Hosting.Exceptions; +using CreativeCoders.Core; +using Spectre.Console; + +namespace CreativeCoders.ShadeKit.Cli.Output; + +/// +/// Reports an expected failure of a command in plain or JSON mode and creates the exception that aborts the command. +/// +internal static class CliCommandAbort +{ + /// + /// Runs an operation of a command and aborts the command if the operation fails with an expected failure. + /// + /// The type of the result of the operation. + /// The operation to run. + /// The mapper that decides which exceptions are expected failures. + /// + /// if the command runs in JSON mode; otherwise, . + /// + /// The writer that receives the failure envelope in JSON mode. + /// The result of . + /// + /// , , or is + /// . + /// + /// + /// threw an exception that maps; it is reported as + /// describes. Any other exception escapes unchanged. + /// + public static T Run(Func operation, CliErrorMapper errorMapper, bool json, ICliJsonWriter jsonWriter) + { + Ensure.NotNull(operation); + Ensure.NotNull(errorMapper); + Ensure.NotNull(jsonWriter); + + try + { + return operation(); + } + catch (Exception e) when (errorMapper.Map(e) is { } error) + { + throw Create(error, e, json, jsonWriter); + } + } + + /// + /// Runs an asynchronous operation of a command and aborts the command if the operation fails with an expected + /// failure. + /// + /// The type of the result of the operation. + /// The operation to run. + /// The mapper that decides which exceptions are expected failures. + /// + /// if the command runs in JSON mode; otherwise, . + /// + /// The writer that receives the failure envelope in JSON mode. + /// + /// A task that represents the asynchronous operation. The task result contains the result of + /// . + /// + /// + /// , , or is + /// . + /// + /// + /// threw an exception that maps; it is reported as + /// describes. Any other exception escapes unchanged. + /// + public static async Task RunAsync(Func> operation, CliErrorMapper errorMapper, bool json, + ICliJsonWriter jsonWriter) + { + Ensure.NotNull(operation); + Ensure.NotNull(errorMapper); + Ensure.NotNull(jsonWriter); + + try + { + return await operation().ConfigureAwait(false); + } + catch (Exception e) when (errorMapper.Map(e) is { } error) + { + throw Create(error, e, json, jsonWriter); + } + } + + /// + /// Reports the specified error and creates the exception that aborts the command with its exit code. + /// + /// + /// In plain mode, nothing is written; the returned exception carries the markup-escaped message, which the CLI + /// host prints. In JSON mode, a failure envelope with the error is written to , and + /// the returned exception carries the raw message and does not print it, so that standard output contains only the + /// envelope. + /// + /// The mapped error that stops the command. + /// The exception that caused the error. + /// + /// if the command runs in JSON mode; otherwise, . + /// + /// The writer that receives the failure envelope in JSON mode. + /// + /// The exception to throw; its exit code is the of and + /// its inner exception is . + /// + /// + /// , , or is + /// . + /// + public static CliCommandAbortException Create(CliError error, Exception exception, bool json, + ICliJsonWriter jsonWriter) + { + Ensure.NotNull(error); + Ensure.NotNull(exception); + Ensure.NotNull(jsonWriter); + + if (!json) + { + return new CliCommandAbortException(Markup.Escape(error.Message), error.ExitCode, exception); + } + + jsonWriter.Write(CliJsonEnvelope.Failure(error.ToJsonMessage())); + + return new CliCommandAbortException(error.Message, error.ExitCode, exception) { PrintMessage = false }; + } +} diff --git a/src/CreativeCoders.ShadeKit.Cli/Output/CliErrorMapper.cs b/src/CreativeCoders.ShadeKit.Cli/Output/CliErrorMapper.cs index f1bc72d..5f99f82 100644 --- a/src/CreativeCoders.ShadeKit.Cli/Output/CliErrorMapper.cs +++ b/src/CreativeCoders.ShadeKit.Cli/Output/CliErrorMapper.cs @@ -1,6 +1,8 @@ using CreativeCoders.Core; using CreativeCoders.ShadeKit.Core.Engine; +using CreativeCoders.ShadeKit.Core.Files; using CreativeCoders.ShadeKit.Core.Formats; +using CreativeCoders.ShadeKit.Core.Init; using CreativeCoders.ShadeKit.Core.Store; namespace CreativeCoders.ShadeKit.Cli.Output; @@ -37,16 +39,16 @@ public sealed class CliErrorMapper(IEngineVersionProvider engineVersionProvider) $"No SHADE kit root directory '{KitStorePaths.RootPath}' found at the repository root " + $"'{e.GitRootPath}'. Run 'shade-kit init' to set up SHADE kit."), + KitRootNotADirectoryException e => new CliError(ShadeKitCliExitCodes.KitRootNotADirectory, + ShadeKitCliErrorCodes.KitRootNotADirectory, + $"'{e.KitRootPath}' is a file, but SHADE kit needs a '{KitStorePaths.RootPath}' directory at the " + + "repository root. Move or remove the file, then run 'shade-kit init'."), + NotAGitRepositoryException e => new CliError(ShadeKitCliExitCodes.NotAGitRepository, ShadeKitCliErrorCodes.NotAGitRepository, $"'{e.StartPath}' is not inside a git repository. SHADE kit works only in a git repository; run " + "shade-kit inside one."), - FeaturesDirectoryNotFoundException e => new CliError(ShadeKitCliExitCodes.FeaturesDirectoryNotFound, - ShadeKitCliErrorCodes.FeaturesDirectoryNotFound, - $"The SHADE kit root directory contains no '{KitStorePaths.FeaturesDirName}' directory " + - $"('{e.FeaturesPath}')."), - FormatTooNewException e => new CliError(ShadeKitCliExitCodes.FormatTooNew, ShadeKitCliErrorCodes.FormatTooNew, CreateFormatTooNewMessage(e)), @@ -57,6 +59,28 @@ public sealed class CliErrorMapper(IEngineVersionProvider engineVersionProvider) InvalidKitFileException e => new CliError(ShadeKitCliExitCodes.InvalidFile, ShadeKitCliErrorCodes.InvalidFile, $"'{e.FilePath}' is invalid: {e.Reason}"), + KitAlreadyInitializedException e => new CliError(ShadeKitCliExitCodes.KitAlreadyInitialized, + ShadeKitCliErrorCodes.KitAlreadyInitialized, + $"SHADE kit is already initialised in this repository: '{e.KitRootPath}' exists. Run " + + "'shade-kit upgrade' to update it."), + + InvalidManagedSectionException e => new CliError(ShadeKitCliExitCodes.InvalidManagedSection, + ShadeKitCliErrorCodes.InvalidManagedSection, + $"The SHADE kit section markers in '{e.FilePath}' are invalid: {e.Reason} Fix the file so that " + + $"every code block is closed and the file contains exactly one " + + $"'{ManagedInstructionSection.BeginMarkerPrefix} ... -->' line followed by exactly one " + + $"'{ManagedInstructionSection.EndMarker}' line outside code blocks, then run the command again."), + + UnsupportedFileEncodingException e => new CliError(ShadeKitCliExitCodes.UnsupportedFileEncoding, + ShadeKitCliErrorCodes.UnsupportedFileEncoding, + $"'{e.FilePath}' is encoded as {e.EncodingName}, but shade-kit can only change UTF-8 files. Convert " + + "the file to UTF-8, then run the command again."), + + PathNotAFileException e => new CliError(ShadeKitCliExitCodes.PathNotAFile, + ShadeKitCliErrorCodes.PathNotAFile, + $"'{e.Path}' is a directory, but shade-kit must write a file at this path. Move or remove the " + + "directory, then run the command again."), + _ => null }; } diff --git a/src/CreativeCoders.ShadeKit.Cli/Output/CliJsonWriter.cs b/src/CreativeCoders.ShadeKit.Cli/Output/CliJsonWriter.cs index e4aaf63..07f6692 100644 --- a/src/CreativeCoders.ShadeKit.Cli/Output/CliJsonWriter.cs +++ b/src/CreativeCoders.ShadeKit.Cli/Output/CliJsonWriter.cs @@ -1,5 +1,6 @@ using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Serialization; using CreativeCoders.Core; namespace CreativeCoders.ShadeKit.Cli.Output; @@ -8,14 +9,16 @@ namespace CreativeCoders.ShadeKit.Cli.Output; /// Writes the JSON output of a command to a text writer, usually standard output. /// /// -/// The document is indented with two spaces, uses camel-case property names and LF line endings, keeps non-ASCII -/// characters unescaped, and ends with a line break. The writer is flushed after every document. The output does not +/// The document is indented with two spaces, uses camel-case property names, lower-case kebab-case enumeration values, +/// and LF line endings, keeps non-ASCII characters unescaped, and ends with a line break. The writer is flushed after every document. The output does not /// go through Spectre.Console, which could wrap long lines. The writer records whether a document was written, so /// that a run in JSON mode never writes a second one. /// /// The text writer that receives the JSON documents. public sealed class CliJsonWriter(TextWriter output) : ICliJsonWriter { + private static readonly JsonNamingPolicy EnumNamingPolicy = JsonNamingPolicy.KebabCaseLower; + private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -23,7 +26,8 @@ public sealed class CliJsonWriter(TextWriter output) : ICliJsonWriter WriteIndented = true, IndentSize = 2, NewLine = "\n", - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Converters = { new JsonStringEnumConverter(EnumNamingPolicy, allowIntegerValues: false) } }; private readonly TextWriter _output = Ensure.NotNull(output); @@ -63,4 +67,17 @@ public void WriteIfNothingWritten(CliJsonEnvelope envelope) Write(envelope); } } + + /// + /// Gets the name with which the JSON output writes an enumeration value, so that plain output can use the same + /// name. + /// + /// The type of the enumeration. + /// The enumeration value. + /// The name of in lower-case kebab case, for example created. + public static string GetEnumName(TEnum value) + where TEnum : struct, Enum + { + return EnumNamingPolicy.ConvertName(value.ToString()); + } } diff --git a/src/CreativeCoders.ShadeKit.Cli/ShadeKitCliErrorCodes.cs b/src/CreativeCoders.ShadeKit.Cli/ShadeKitCliErrorCodes.cs index 2641a3e..9a8d5a3 100644 --- a/src/CreativeCoders.ShadeKit.Cli/ShadeKitCliErrorCodes.cs +++ b/src/CreativeCoders.ShadeKit.Cli/ShadeKitCliErrorCodes.cs @@ -16,14 +16,24 @@ public static class ShadeKitCliErrorCodes public const string KitRootNotFound = "kit-root-not-found"; /// - /// Represents the error code that belongs to . + /// Represents the error code that belongs to . /// - public const string FeaturesDirectoryNotFound = "features-dir-not-found"; + public const string NotAGitRepository = "not-a-git-repository"; /// - /// Represents the error code that belongs to . + /// Represents the error code that belongs to . /// - public const string NotAGitRepository = "not-a-git-repository"; + public const string KitAlreadyInitialized = "already-initialized"; + + /// + /// Represents the error code that belongs to . + /// + public const string PathNotAFile = "path-not-a-file"; + + /// + /// Represents the error code that belongs to . + /// + public const string KitRootNotADirectory = "kit-root-not-a-directory"; /// /// Represents the error code that belongs to . @@ -41,6 +51,16 @@ public static class ShadeKitCliErrorCodes /// public const string InvalidFile = "invalid-file"; + /// + /// Represents the error code that belongs to . + /// + public const string InvalidManagedSection = "invalid-managed-section"; + + /// + /// Represents the error code that belongs to . + /// + public const string UnsupportedFileEncoding = "unsupported-encoding"; + /// /// Represents the error code that belongs to . /// diff --git a/src/CreativeCoders.ShadeKit.Cli/ShadeKitCliExitCodes.cs b/src/CreativeCoders.ShadeKit.Cli/ShadeKitCliExitCodes.cs index f8012ef..a6d213a 100644 --- a/src/CreativeCoders.ShadeKit.Cli/ShadeKitCliExitCodes.cs +++ b/src/CreativeCoders.ShadeKit.Cli/ShadeKitCliExitCodes.cs @@ -18,18 +18,33 @@ public static class ShadeKitCliExitCodes ///
public const int KitRootNotFound = 10; - /// - /// Represents the exit code returned when the SHADE kit root directory exists but contains no - /// features directory. The error code is . - /// - public const int FeaturesDirectoryNotFound = 11; - /// /// Represents the exit code returned when the current directory is not inside a git repository. The error code is /// . /// public const int NotAGitRepository = 12; + /// + /// Represents the exit code returned when shade-kit init runs in a git repository whose root already + /// contains a SHADE kit root directory (.shadekit). The error code is + /// . + /// + public const int KitAlreadyInitialized = 13; + + /// + /// Represents the exit code returned when a path at which a command must write a file, such as + /// .gitignore, CLAUDE.md, or AGENTS.md at the repository root, is a directory. The error code + /// is . + /// + public const int PathNotAFile = 14; + + /// + /// Represents the exit code returned when the SHADE kit root path (.shadekit) at the root of the git + /// repository is a file instead of a directory. The error code is + /// . + /// + public const int KitRootNotADirectory = 15; + /// /// Represents the exit code returned when a file uses a format version that is newer than the engine supports. /// The error code is . @@ -49,6 +64,20 @@ public static class ShadeKitCliExitCodes /// public const int InvalidFile = 22; + /// + /// Represents the exit code returned when the markers of the managed SHADE kit section in CLAUDE.md or + /// AGENTS.md are invalid, so the section cannot be located safely. The error code is + /// . + /// + public const int InvalidManagedSection = 23; + + /// + /// Represents the exit code returned when a file that a command changes in place, such as + /// .gitignore, CLAUDE.md, or AGENTS.md, is not encoded as UTF-8 because it starts with a + /// UTF-16 or UTF-32 byte order mark. The error code is . + /// + public const int UnsupportedFileEncoding = 24; + /// /// Represents the exit code returned when the CLI host itself fails, for example because the command is unknown, /// cannot be created, or its options are invalid. The error code is . diff --git a/src/CreativeCoders.ShadeKit.Core/Config/KitConfig.cs b/src/CreativeCoders.ShadeKit.Core/Config/KitConfig.cs index a2ee513..f0a9e26 100644 --- a/src/CreativeCoders.ShadeKit.Core/Config/KitConfig.cs +++ b/src/CreativeCoders.ShadeKit.Core/Config/KitConfig.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using System.Text.Json.Serialization; using CreativeCoders.ShadeKit.Core.Formats; namespace CreativeCoders.ShadeKit.Core.Config; @@ -6,12 +8,31 @@ namespace CreativeCoders.ShadeKit.Core.Config; /// Represents the repository configuration stored in .shadekit/config.json. /// /// -/// The team owns the values; the engine owns the schema. This model contains only the fields the engine reads so -/// far. All other fields, such as kitVersion or trackers, are kept in -/// and written back unchanged. +/// The team owns the values; the engine owns the schema. This model holds the fields of format version 1. +/// Fields the engine does not know are kept in and written back +/// unchanged. The fields are written in the order in which the properties are declared, after +/// and . /// -public sealed class KitConfig : VersionedDocument +public sealed class KitConfig : VersionedDocument, IJsonOnDeserialized { + /// + /// Gets or sets the kit version that initialised or last upgraded the repository. + /// + /// + /// A semantic version string, or if the field is missing. The value is informational only; + /// format checks use . A value is not written. + /// + public string? KitVersion { get; set; } + + /// + /// Gets or sets the workflow profile that shade-kit feature new uses when none is given. + /// + /// + /// The name of a workflow profile, for example feature, bugfix, or quick. The default is + /// feature, which also applies when the field is missing. + /// + public string DefaultProfile { get; set; } = "feature"; + /// /// Gets or sets what read commands do with a versioned file in an older format version. /// @@ -20,4 +41,48 @@ public sealed class KitConfig : VersionedDocument /// also applies when the field is missing. /// public OutdatedFormatOnReadPolicy OutdatedFormatOnRead { get; set; } = OutdatedFormatOnReadPolicy.MigrateAndWarn; + + /// + /// Gets or sets the mapping from issue URL host to the id prefix of feature ids created from such an issue. + /// + /// + /// The id prefixes keyed by host name. The default maps github.com to gh, which also applies when the + /// field is missing; an empty mapping turns issue-based ids off. Host names are written exactly as given. A + /// field or a host with a id prefix makes the file invalid. + /// + public Dictionary Trackers { get; set; } = new() { ["github.com"] = "gh" }; + + /// + /// Gets or sets whether the finish phase writes a report.md for a feature. + /// + /// + /// One of the enumeration values. The default is , which also applies when the + /// field is missing. + /// + public ReportPolicy Report { get; set; } = ReportPolicy.Ask; + + /// + /// Gets or sets the minimum coverage that the verify phase requires. + /// + /// + /// The minimum coverage, or if the field is missing, which means that + /// verify only measures and records the coverage. A value is not written; a number + /// beyond the range of makes the file invalid. + /// + public double? CoverageThreshold { get; set; } + + void IJsonOnDeserialized.OnDeserialized() + { + // RespectNullableAnnotations does not cover dictionary values, so a null id prefix is rejected here. + if (Trackers.Values.Any(prefix => prefix is null)) + { + throw new JsonException("The 'trackers' field contains a null id prefix."); + } + + // A number beyond the double range is read as infinity, which cannot be written back. + if (CoverageThreshold is { } threshold && !double.IsFinite(threshold)) + { + throw new JsonException("The 'coverageThreshold' field is out of range."); + } + } } diff --git a/src/CreativeCoders.ShadeKit.Core/Config/ReportPolicy.cs b/src/CreativeCoders.ShadeKit.Core/Config/ReportPolicy.cs new file mode 100644 index 0000000..9d9318d --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Config/ReportPolicy.cs @@ -0,0 +1,22 @@ +namespace CreativeCoders.ShadeKit.Core.Config; + +/// +/// Specifies whether the finish phase writes a report.md for a feature. +/// +public enum ReportPolicy +{ + /// + /// The finish phase asks the human whether to write the report. Stored as ask. + /// + Ask, + + /// + /// The finish phase always writes the report. Stored as always. + /// + Always, + + /// + /// The finish phase never writes the report. Stored as never. + /// + Never +} diff --git a/src/CreativeCoders.ShadeKit.Core/CreativeCoders.ShadeKit.Core.csproj b/src/CreativeCoders.ShadeKit.Core/CreativeCoders.ShadeKit.Core.csproj index 844be6f..a35d15c 100644 --- a/src/CreativeCoders.ShadeKit.Core/CreativeCoders.ShadeKit.Core.csproj +++ b/src/CreativeCoders.ShadeKit.Core/CreativeCoders.ShadeKit.Core.csproj @@ -9,4 +9,15 @@
+ + + + + templates/$([System.String]::Copy('%(RecursiveDir)').Replace('\', '/'))%(Filename)%(Extension) + + +
diff --git a/src/CreativeCoders.ShadeKit.Core/Files/PathNotAFileException.cs b/src/CreativeCoders.ShadeKit.Core/Files/PathNotAFileException.cs new file mode 100644 index 0000000..f5a2934 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Files/PathNotAFileException.cs @@ -0,0 +1,16 @@ +namespace CreativeCoders.ShadeKit.Core.Files; + +/// +/// Represents the exception that is thrown when a path at which SHADE kit reads and writes a file is an +/// existing directory. +/// +/// The path that is a directory. +public class PathNotAFileException(string path) + : Exception($"The path '{path}' is a directory, but a file is expected.") +{ + /// + /// Gets the path that is a directory instead of a file. + /// + /// The path as it was passed to the reader. + public string Path { get; } = path; +} diff --git a/src/CreativeCoders.ShadeKit.Core/Files/TextFileContent.cs b/src/CreativeCoders.ShadeKit.Core/Files/TextFileContent.cs new file mode 100644 index 0000000..aad323f --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Files/TextFileContent.cs @@ -0,0 +1,226 @@ +using System.Text; +using CreativeCoders.Core; + +namespace CreativeCoders.ShadeKit.Core.Files; + +/// +/// Represents the content of a text file that SHADE kit changes in place, so that every byte outside the +/// changed part stays as it is. +/// +/// +/// +/// The content is kept as bytes. Lines are decoded only to match them; they are never encoded again, so a file with +/// any encoding that is compatible with ASCII line terminators keeps its bytes outside the changed part. A file that +/// starts with a UTF-16 or UTF-32 byte order mark, or that contains a NUL byte, which UTF-16 and UTF-32 without byte +/// order mark do, is refused when it is read, because UTF-8 text inserted into it would mix two encodings. +/// +/// +/// Inserted text is passed as a block of lines separated by \n, without a final line terminator. It is written +/// as UTF-8 with the line terminator of the file: \r\n if the file contains one, otherwise \n. A +/// leading UTF-8 byte order mark is kept. A new file is written as UTF-8 without byte order mark and with \n. +/// +/// +internal sealed class TextFileContent +{ + private const string Lf = "\n"; + + private const string CrLf = "\r\n"; + + private const string UnmarkedWideEncodingName = "UTF-16 or UTF-32 without byte order mark"; + + private readonly byte[] _bytes; + + // The offset at which the text starts, after a leading UTF-8 byte order mark. + private readonly int _textStart; + + // The byte order marks of the encodings that are not compatible with ASCII line terminators. UTF-32 LE starts + // with the UTF-16 LE mark, so it must be checked first. + private static readonly (byte[] Bom, string EncodingName)[] UnsupportedBoms = + [ + ([0xFF, 0xFE, 0x00, 0x00], "UTF-32 LE"), + ([0x00, 0x00, 0xFE, 0xFF], "UTF-32 BE"), + ([0xFF, 0xFE], "UTF-16 LE"), + ([0xFE, 0xFF], "UTF-16 BE") + ]; + + private static ReadOnlySpan Utf8Bom => [0xEF, 0xBB, 0xBF]; + + private TextFileContent(byte[] bytes) + { + _bytes = bytes; + _textStart = bytes.AsSpan().StartsWith(Utf8Bom) ? Utf8Bom.Length : 0; + NewLine = bytes.AsSpan().IndexOf("\r\n"u8) >= 0 ? CrLf : Lf; + Lines = SplitLines(bytes, _textStart); + } + + /// + /// Gets the lines of the file. + /// + /// The lines in file order; a final line terminator does not start another line. + public IReadOnlyList Lines { get; } + + /// + /// Gets the line terminator that inserted text uses. + /// + /// \r\n if the file contains one; otherwise, \n. + public string NewLine { get; } + + /// + /// Reads the content of a text file. + /// + /// The absolute path of the file. + /// The token to monitor for cancellation requests. + /// + /// A task that represents the asynchronous read operation. The task result contains the content of the file, or + /// if the file does not exist. + /// + /// + /// is , empty, whitespace, or not an absolute path. + /// + /// is an existing directory. + /// + /// The file starts with a UTF-16 or UTF-32 byte order mark or contains a NUL byte. + /// + /// was canceled. + public static async Task ReadAsync(string filePath, CancellationToken cancellationToken) + { + Ensure.IsNotNullOrWhitespace(filePath); + + if (!Path.IsPathFullyQualified(filePath)) + { + throw new ArgumentException($"The path '{filePath}' must be absolute.", nameof(filePath)); + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (Directory.Exists(filePath)) + { + throw new PathNotAFileException(filePath); + } + + if (!File.Exists(filePath)) + { + return null; + } + + var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false); + + if (FindUnsupportedEncodingName(bytes) is { } encodingName) + { + throw new UnsupportedFileEncodingException(filePath, encodingName); + } + + return new TextFileContent(bytes); + } + + /// + /// Creates the bytes of a new file that contains only a block of lines. + /// + /// The lines of the file, separated by \n, without a final line terminator. + /// The block followed by \n, encoded as UTF-8 without byte order mark. + public static byte[] CreateNew(string block) + { + return Encoding.UTF8.GetBytes(block + Lf); + } + + /// + /// Creates the bytes of the file with a block of lines added at its end. + /// + /// + /// A missing final line terminator is added first. If the file has content, one blank line separates it from the + /// block; no blank line is added if the file already ends with an empty line. The block ends with a line + /// terminator. + /// + /// The lines to add, separated by \n, without a final line terminator. + /// The unchanged bytes of the file followed by the added text. + public byte[] AppendBlock(string block) + { + var text = new StringBuilder(); + + if (_bytes.Length > _textStart) + { + if (_bytes[^1] != (byte)'\n') + { + text.Append(NewLine); + } + + if (!EndsWithEmptyLine) + { + text.Append(NewLine); + } + } + + text.Append(ToFileLineEndings(block)).Append(NewLine); + + return [.. _bytes, .. Encoding.UTF8.GetBytes(text.ToString())]; + } + + /// + /// Creates the bytes of the file with a range of lines replaced by a block of lines. + /// + /// The first line to replace. + /// The last line to replace; its line terminator is kept. + /// The replacement lines, separated by \n, without a final line terminator. + /// The unchanged bytes before , the block, and the unchanged bytes from the line + /// terminator of on. + public byte[] ReplaceLines(TextFileLine first, TextFileLine last, string block) + { + return + [ + .. _bytes.AsSpan(0, first.Start), + .. Encoding.UTF8.GetBytes(ToFileLineEndings(block)), + .. _bytes.AsSpan(last.ContentEnd) + ]; + } + + /// + /// Determines whether the file consists of exactly the specified bytes. + /// + /// The bytes to compare with the file. + /// if the file has the same bytes; otherwise, . + public bool HasBytes(ReadOnlySpan bytes) + { + return bytes.SequenceEqual(_bytes); + } + + // A last line without line terminator is never empty, so an empty last line is always followed by one. + private bool EndsWithEmptyLine => Lines.Count > 0 && Lines[^1].Text.Length == 0; + + private static string? FindUnsupportedEncodingName(ReadOnlySpan bytes) + { + foreach (var (bom, encodingName) in UnsupportedBoms) + { + if (bytes.StartsWith(bom)) + { + return encodingName; + } + } + + // UTF-8 text practically never contains a NUL character, but UTF-16 and UTF-32 encode every ASCII character + // with NUL bytes. + return bytes.Contains((byte)0) ? UnmarkedWideEncodingName : null; + } + + private string ToFileLineEndings(string block) + { + return block.ReplaceLineEndings(NewLine); + } + + private static TextFileLine[] SplitLines(byte[] bytes, int textStart) + { + var lines = new List(); + + for (var start = textStart; start < bytes.Length;) + { + var lineFeedOffset = bytes.AsSpan(start).IndexOf((byte)'\n'); + var end = lineFeedOffset < 0 ? bytes.Length : start + lineFeedOffset; + var contentEnd = lineFeedOffset >= 0 && end > start && bytes[end - 1] == (byte)'\r' ? end - 1 : end; + + lines.Add(new TextFileLine(Encoding.UTF8.GetString(bytes, start, contentEnd - start), start, contentEnd)); + + start = end + 1; + } + + return [.. lines]; + } +} diff --git a/src/CreativeCoders.ShadeKit.Core/Files/TextFileLine.cs b/src/CreativeCoders.ShadeKit.Core/Files/TextFileLine.cs new file mode 100644 index 0000000..b4b2ed3 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Files/TextFileLine.cs @@ -0,0 +1,10 @@ +namespace CreativeCoders.ShadeKit.Core.Files; + +/// +/// Represents one line of a together with its position in the bytes of the file. +/// +/// The decoded text of the line, without its line terminator. It is used for matching only. +/// The byte offset at which the line starts, after a leading byte order mark. +/// The byte offset at which the line terminator (\n or \r\n) starts, or the +/// length of the file for a last line without terminator. +internal readonly record struct TextFileLine(string Text, int Start, int ContentEnd); diff --git a/src/CreativeCoders.ShadeKit.Core/Files/UnsupportedFileEncodingException.cs b/src/CreativeCoders.ShadeKit.Core/Files/UnsupportedFileEncodingException.cs new file mode 100644 index 0000000..6656652 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Files/UnsupportedFileEncodingException.cs @@ -0,0 +1,31 @@ +namespace CreativeCoders.ShadeKit.Core.Files; + +/// +/// Represents the exception that is thrown when a text file that SHADE kit changes in place is not encoded +/// as UTF-8, so text cannot be inserted without mixing encodings. +/// +/// +/// The encoding is recognised by the byte order mark at the start of the file: UTF-16 or UTF-32, each in little- or +/// big-endian byte order. A file without byte order mark that contains a NUL byte is taken as UTF-16 or UTF-32 without +/// byte order mark. +/// +/// The path of the file. +/// The name of the encoding of the file, for example UTF-16 LE. +public class UnsupportedFileEncodingException(string filePath, string encodingName) + : Exception($"The file '{filePath}' is encoded as {encodingName}; only UTF-8 files can be changed.") +{ + /// + /// Gets the path of the file whose encoding is not supported. + /// + /// The path of the file as it was passed to the reader. + public string FilePath { get; } = filePath; + + /// + /// Gets the name of the encoding of the file. + /// + /// + /// One of UTF-16 LE, UTF-16 BE, UTF-32 LE, or UTF-32 BE, after the byte order mark at + /// the start of the file, or UTF-16 or UTF-32 without byte order mark for a file with a NUL byte. + /// + public string EncodingName { get; } = encodingName; +} diff --git a/src/CreativeCoders.ShadeKit.Core/Formats/FormatRegistry.cs b/src/CreativeCoders.ShadeKit.Core/Formats/FormatRegistry.cs index c8ab118..4b4b452 100644 --- a/src/CreativeCoders.ShadeKit.Core/Formats/FormatRegistry.cs +++ b/src/CreativeCoders.ShadeKit.Core/Formats/FormatRegistry.cs @@ -24,16 +24,18 @@ public FormatRegistry(IEnumerable formats) Ensure.NotNull(formats); Formats = [.. formats.Select(format => Ensure.NotNull(format))]; - _formatsById = new Dictionary(StringComparer.Ordinal); - foreach (var format in Formats) + var duplicateId = Formats + .GroupBy(format => format.Id, StringComparer.Ordinal) + .FirstOrDefault(group => group.Skip(1).Any())?.Key; + + if (duplicateId is not null) { - if (!_formatsById.TryAdd(format.Id, format)) - { - throw new ArgumentException($"File type '{format.Id}' is registered more than once.", - nameof(formats)); - } + throw new ArgumentException($"File type '{duplicateId}' is registered more than once.", + nameof(formats)); } + + _formatsById = Formats.ToDictionary(format => format.Id, StringComparer.Ordinal); } /// diff --git a/src/CreativeCoders.ShadeKit.Core/Gates/GatesConfig.cs b/src/CreativeCoders.ShadeKit.Core/Gates/GatesConfig.cs new file mode 100644 index 0000000..9003a2f --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Gates/GatesConfig.cs @@ -0,0 +1,13 @@ +using CreativeCoders.ShadeKit.Core.Formats; + +namespace CreativeCoders.ShadeKit.Core.Gates; + +/// +/// Represents the gate configuration stored in .shadekit/gates.json. +/// +/// +/// The model has no fields of its own yet; package 05 adds the gate definitions. Until then, every field other than +/// and is kept in +/// and written back unchanged. +/// +public sealed class GatesConfig : VersionedDocument; diff --git a/src/CreativeCoders.ShadeKit.Core/Init/GitIgnorePlanner.cs b/src/CreativeCoders.ShadeKit.Core/Init/GitIgnorePlanner.cs new file mode 100644 index 0000000..f943471 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Init/GitIgnorePlanner.cs @@ -0,0 +1,44 @@ +using CreativeCoders.ShadeKit.Core.Files; + +namespace CreativeCoders.ShadeKit.Core.Init; + +/// +/// Plans the change that makes a .gitignore file ignore the local-only files of SHADE kit. +/// +public sealed class GitIgnorePlanner : IGitIgnorePlanner +{ + private const string CommentLine = "# SHADE kit: local-only files"; + + /// + public async Task PlanAsync(string filePath, CancellationToken cancellationToken = default) + { + var content = await TextFileContent.ReadAsync(filePath, cancellationToken).ConfigureAwait(false); + + if (content is null) + { + return new PlannedFileChange(TextFileContent.CreateNew(CreateBlock(KitGitIgnoreEntries.All)), + InitializedFileAction.Created); + } + + var missingEntries = KitGitIgnoreEntries.All + .Where(entry => !content.Lines.Any(line => IsEntryLine(line.Text, entry))) + .ToArray(); + + return missingEntries.Length == 0 + ? null + : new PlannedFileChange(content.AppendBlock(CreateBlock(missingEntries)), InitializedFileAction.Appended); + } + + private static string CreateBlock(IEnumerable entries) + { + return string.Join('\n', [CommentLine, .. entries]); + } + + private static bool IsEntryLine(string lineText, string entry) + { + // git ignores trailing spaces of a pattern, but keeps leading whitespace and trailing tabs as part of it. + var trimmedText = lineText.TrimEnd(' '); + + return trimmedText == entry || trimmedText == "/" + entry; + } +} diff --git a/src/CreativeCoders.ShadeKit.Core/Init/IGitIgnorePlanner.cs b/src/CreativeCoders.ShadeKit.Core/Init/IGitIgnorePlanner.cs new file mode 100644 index 0000000..748603f --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Init/IGitIgnorePlanner.cs @@ -0,0 +1,46 @@ +using CreativeCoders.ShadeKit.Core.Files; + +namespace CreativeCoders.ShadeKit.Core.Init; + +/// +/// Plans the change that makes a .gitignore file ignore the local-only files of SHADE kit. +/// +public interface IGitIgnorePlanner +{ + /// + /// Computes the change that makes a .gitignore file contain every entry of + /// , without writing it. + /// + /// + /// + /// An entry is present if a line, without trailing spaces, equals the entry or the entry prefixed with /. + /// This follows git, which ignores trailing spaces of a pattern but treats leading whitespace and trailing tabs + /// as part of it, so a line with leading whitespace or a trailing tab does not count. Missing entries are + /// appended without a leading /, under the comment line # SHADE kit: local-only files; one blank + /// line separates them from existing content, unless the file already ends with an empty line. + /// + /// + /// A missing file gets new content as UTF-8 without byte order mark and with \n line terminators. In an + /// existing file, every existing byte stays unchanged, a leading UTF-8 byte order mark is kept, a missing final + /// line terminator is added, and the appended lines end with \r\n if the file contains one. A file that is + /// not UTF-8 is refused. + /// + /// + /// The absolute path of the .gitignore file. + /// The token to monitor for cancellation requests. + /// + /// A task that represents the asynchronous read operation. The task result contains the new content with + /// if the file does not exist or + /// if entries are appended, or if the file + /// already contains every entry and needs no change. + /// + /// + /// is , empty, whitespace, or not an absolute path. + /// + /// is an existing directory. + /// + /// The file starts with a UTF-16 or UTF-32 byte order mark or contains a NUL byte. + /// + /// was canceled. + Task PlanAsync(string filePath, CancellationToken cancellationToken = default); +} diff --git a/src/CreativeCoders.ShadeKit.Core/Init/IInstructionSectionPlanner.cs b/src/CreativeCoders.ShadeKit.Core/Init/IInstructionSectionPlanner.cs new file mode 100644 index 0000000..6786ced --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Init/IInstructionSectionPlanner.cs @@ -0,0 +1,58 @@ +using CreativeCoders.ShadeKit.Core.Files; + +namespace CreativeCoders.ShadeKit.Core.Init; + +/// +/// Plans the managed SHADE kit section of an agent instruction file such as CLAUDE.md or +/// AGENTS.md. +/// +/// +/// The section starts with a line whose text, without leading and trailing whitespace, starts with +/// followed by whitespace or -->, and ends with a +/// line whose text, without leading and trailing whitespace, is . +/// Lines in a fenced code block (opened by at least three backticks or tildes, indented by at most three spaces) are +/// no markers, so the markers can be documented in a code example. A code block that is still open at the end of the +/// file makes the file invalid, because a section appended after it would be part of the block. The markers are +/// invalid if a file +/// contains more than one begin or end marker, a begin marker without an end marker, an end marker without a begin +/// marker, or an end marker before the begin marker. +/// +public interface IInstructionSectionPlanner +{ + /// + /// Computes the change that writes the managed section into an + /// instruction file, without writing it. + /// + /// + /// + /// A missing file gets only the section, as UTF-8 without byte order mark and with \n line + /// terminators. To a file without markers, the section is appended after one blank line, or directly if the file + /// already ends with an empty line. In a file with one valid pair of markers, the lines from the begin marker to + /// the end marker are replaced by the section. + /// + /// + /// In an existing file, every byte outside the inserted or replaced part stays unchanged, a leading UTF-8 byte + /// order mark is kept, a missing final line terminator is added before appending, and the section uses + /// \r\n line terminators if the file contains one. A file with invalid markers or that is not UTF-8 is + /// refused. + /// + /// + /// The absolute path of the instruction file. + /// The token to monitor for cancellation requests. + /// + /// A task that represents the asynchronous read operation. The task result contains the new content with + /// , , or + /// , or if the file already contains the + /// section exactly and needs no change. + /// + /// + /// is , empty, whitespace, or not an absolute path. + /// + /// is an existing directory. + /// + /// The file starts with a UTF-16 or UTF-32 byte order mark or contains a NUL byte. + /// + /// The markers in the file are invalid. + /// was canceled. + Task PlanAsync(string filePath, CancellationToken cancellationToken = default); +} diff --git a/src/CreativeCoders.ShadeKit.Core/Init/IRepositoryInitializer.cs b/src/CreativeCoders.ShadeKit.Core/Init/IRepositoryInitializer.cs new file mode 100644 index 0000000..c32bb70 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Init/IRepositoryInitializer.cs @@ -0,0 +1,59 @@ +using CreativeCoders.ShadeKit.Core.Store; + +namespace CreativeCoders.ShadeKit.Core.Init; + +/// +/// Initialises a git repository for SHADE kit, as shade-kit init does. +/// +public interface IRepositoryInitializer +{ + /// + /// Initialises the git repository that contains the specified directory. + /// + /// + /// + /// The initialisation works at the root of the git repository, also when is a + /// subdirectory. Before anything is written, it checks that the repository root contains no .shadekit + /// directory or file; that .gitignore, CLAUDE.md, and AGENTS.md are files or missing, and + /// UTF-8 if they exist; and that the managed section markers in CLAUDE.md and AGENTS.md are valid. + /// + /// + /// It then creates .shadekit/ with config.json, gates.json, every shipped template, + /// templates.lock.json, and an empty features/ directory; adds the local-only files to + /// .gitignore; and writes the managed section into CLAUDE.md and AGENTS.md, in this order. + /// If a write fails or the operation is canceled after .shadekit/ was created, .shadekit/ is + /// removed again, so that the next initialisation can start over. Repository files that are already written stay + /// in place; the next initialisation recognises their changes and does not repeat them. + /// + /// + /// The directory at which the search for the repository root starts. + /// The token to monitor for cancellation requests. + /// + /// A task that represents the asynchronous initialisation. The task result contains the repository root and the + /// files that were created or changed. + /// + /// + /// is , empty, or whitespace. + /// + /// + /// is not inside a git repository. + /// + /// + /// The repository root already contains a .shadekit directory. + /// + /// + /// The repository root contains a .shadekit file. + /// + /// + /// .gitignore, CLAUDE.md, or AGENTS.md at the repository root is a directory. + /// + /// + /// .gitignore, CLAUDE.md, or AGENTS.md at the repository root starts with a UTF-16 or UTF-32 + /// byte order mark or contains a NUL byte. + /// + /// + /// The managed section markers in CLAUDE.md or AGENTS.md are invalid. + /// + /// was canceled. + Task InitializeAsync(string path, CancellationToken cancellationToken = default); +} diff --git a/src/CreativeCoders.ShadeKit.Core/Init/InitializedFile.cs b/src/CreativeCoders.ShadeKit.Core/Init/InitializedFile.cs new file mode 100644 index 0000000..4bf6125 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Init/InitializedFile.cs @@ -0,0 +1,11 @@ +namespace CreativeCoders.ShadeKit.Core.Init; + +/// +/// Represents a file or directory that the initialisation of a repository created or changed. +/// +/// +/// The path relative to the repository root, with / as separator, for example .shadekit/config.json. A +/// directory ends with /, for example .shadekit/features/. +/// +/// One of the enumeration values that specifies how the file was changed. +public sealed record InitializedFile(string Path, InitializedFileAction Action); diff --git a/src/CreativeCoders.ShadeKit.Core/Init/InitializedFileAction.cs b/src/CreativeCoders.ShadeKit.Core/Init/InitializedFileAction.cs new file mode 100644 index 0000000..a266971 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Init/InitializedFileAction.cs @@ -0,0 +1,22 @@ +namespace CreativeCoders.ShadeKit.Core.Init; + +/// +/// Specifies how the initialisation of a repository changed a file. +/// +public enum InitializedFileAction +{ + /// + /// The file did not exist and was created. Reported as created. + /// + Created, + + /// + /// The file existed and content was added to its end. Reported as appended. + /// + Appended, + + /// + /// The file existed and a part of its content was replaced. Reported as updated. + /// + Updated +} diff --git a/src/CreativeCoders.ShadeKit.Core/Init/InstructionSectionPlanner.cs b/src/CreativeCoders.ShadeKit.Core/Init/InstructionSectionPlanner.cs new file mode 100644 index 0000000..100fab2 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Init/InstructionSectionPlanner.cs @@ -0,0 +1,174 @@ +using CreativeCoders.ShadeKit.Core.Files; + +namespace CreativeCoders.ShadeKit.Core.Init; + +/// +/// Plans the managed SHADE kit section of an agent instruction file such as CLAUDE.md or +/// AGENTS.md. +/// +public sealed class InstructionSectionPlanner : IInstructionSectionPlanner +{ + private const string CommentEnd = "-->"; + + private const int MaxFenceIndent = 3; + + private const int MinFenceLength = 3; + + /// + public async Task PlanAsync(string filePath, CancellationToken cancellationToken = default) + { + var content = await TextFileContent.ReadAsync(filePath, cancellationToken).ConfigureAwait(false); + + if (content is null) + { + return new PlannedFileChange(TextFileContent.CreateNew(ManagedInstructionSection.Text), + InitializedFileAction.Created); + } + + if (FindSection(content, filePath) is not { } section) + { + return new PlannedFileChange(content.AppendBlock(ManagedInstructionSection.Text), + InitializedFileAction.Appended); + } + + var newBytes = content.ReplaceLines(section.Begin, section.End, ManagedInstructionSection.Text); + + return content.HasBytes(newBytes) ? null : new PlannedFileChange(newBytes, InitializedFileAction.Updated); + } + + /// + /// Finds the lines of the begin and end marker of the managed section. + /// + /// The marker lines, or if the file contains no marker. + /// The markers in the file are invalid. + private static (TextFileLine Begin, TextFileLine End)? FindSection(TextFileContent content, string filePath) + { + // Markers in a fenced code block are examples, for example in documentation of the markers, not the section. + var (lines, unclosedFenceIndex) = GetTrimmedLinesOutsideCodeFences(content); + + // A section appended after an unclosed block would be part of it and not be found again, so every later + // init would append another one. + if (unclosedFenceIndex is { } fenceIndex) + { + throw new InvalidManagedSectionException(filePath, + $"The code block opened in line {fenceIndex + 1} is never closed, so the section cannot be placed " + + "after it."); + } + + var beginIndexes = FindLineIndexes(lines, IsBeginMarker); + var endIndexes = FindLineIndexes(lines, text => text == ManagedInstructionSection.EndMarker); + + var reason = (beginIndexes.Length, endIndexes.Length) switch + { + (0, 0) => null, + (> 1, _) => $"The file contains more than one begin marker (lines {FormatLineNumbers(beginIndexes)}).", + (_, > 1) => $"The file contains more than one end marker (lines {FormatLineNumbers(endIndexes)}).", + (1, 0) => $"The begin marker in line {beginIndexes[0] + 1} has no end marker.", + (0, 1) => $"The end marker in line {endIndexes[0] + 1} has no begin marker.", + _ when endIndexes[0] < beginIndexes[0] => + $"The end marker in line {endIndexes[0] + 1} comes before the begin marker in line " + + $"{beginIndexes[0] + 1}.", + _ => null + }; + + if (reason is not null) + { + throw new InvalidManagedSectionException(filePath, reason); + } + + return beginIndexes.Length == 0 + ? null + : (content.Lines[beginIndexes[0]], content.Lines[endIndexes[0]]); + } + + private static bool IsBeginMarker(string text) + { + if (!text.StartsWith(ManagedInstructionSection.BeginMarkerPrefix, StringComparison.Ordinal)) + { + return false; + } + + // The prefix must end a word, so that for example ""; + + /// + /// The managed section, from the begin marker line to the end marker line, with \n line terminators and + /// without a final line terminator. + /// + public const string Text = + BeginMarkerPrefix + " - managed by shade-kit, content between the markers is replaced on upgrade -->\n" + + "## SHADE kit\n" + + "\n" + + "This repository uses SHADE kit. Its data lives in `.shadekit/` (configuration, templates, features) and is " + + "committed with the code.\n" + + "\n" + + "- To start or continue work on a feature, use the `shade-kit-next-phase` skill.\n" + + "- Call the engine as `shade-kit --json` and act on its JSON result.\n" + + "- Change feature state only through `shade-kit` commands, never by editing `.shadekit/features/` by hand.\n" + + EndMarker; +} diff --git a/src/CreativeCoders.ShadeKit.Core/Init/PlannedFileChange.cs b/src/CreativeCoders.ShadeKit.Core/Init/PlannedFileChange.cs new file mode 100644 index 0000000..345d28a --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Init/PlannedFileChange.cs @@ -0,0 +1,8 @@ +namespace CreativeCoders.ShadeKit.Core.Init; + +/// +/// Represents the change of a repository file that the initialisation writes, computed before anything is written. +/// +/// The complete bytes the file is written with. +/// One of the enumeration values that specifies how the write changes the file. +public sealed record PlannedFileChange(byte[] Content, InitializedFileAction Action); diff --git a/src/CreativeCoders.ShadeKit.Core/Init/RepositoryInitResult.cs b/src/CreativeCoders.ShadeKit.Core/Init/RepositoryInitResult.cs new file mode 100644 index 0000000..67f9558 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Init/RepositoryInitResult.cs @@ -0,0 +1,11 @@ +namespace CreativeCoders.ShadeKit.Core.Init; + +/// +/// Represents the result of the initialisation of a repository. +/// +/// The full path of the root directory of the initialised git repository. +/// +/// The files and directories that were created or changed, in the order in which they were written. A file that +/// already had the required content is not listed. +/// +public sealed record RepositoryInitResult(string RepositoryRootPath, IReadOnlyList Files); diff --git a/src/CreativeCoders.ShadeKit.Core/Init/RepositoryInitializer.cs b/src/CreativeCoders.ShadeKit.Core/Init/RepositoryInitializer.cs new file mode 100644 index 0000000..f477f9d --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Init/RepositoryInitializer.cs @@ -0,0 +1,229 @@ +using System.Text; +using CreativeCoders.Core; +using CreativeCoders.ShadeKit.Core.Config; +using CreativeCoders.ShadeKit.Core.Engine; +using CreativeCoders.ShadeKit.Core.Formats; +using CreativeCoders.ShadeKit.Core.Gates; +using CreativeCoders.ShadeKit.Core.Store; +using CreativeCoders.ShadeKit.Core.Templates; + +namespace CreativeCoders.ShadeKit.Core.Init; + +/// +/// Initialises a git repository for SHADE kit, as shade-kit init does. +/// +/// The locator that finds the root of the git repository. +/// The factory that creates the store of the new .shadekit directory. +/// The reader that provides the content of the shipped templates. +/// The planner that computes the local-only entries to add to .gitignore. +/// +/// The planner that computes the managed section to add to CLAUDE.md and AGENTS.md. +/// +/// The provider of the engine version recorded as kit version. +public sealed class RepositoryInitializer( + IGitRepositoryLocator gitRepositoryLocator, + IKitStoreFactory kitStoreFactory, + IShippedTemplateReader shippedTemplateReader, + IGitIgnorePlanner gitIgnorePlanner, + IInstructionSectionPlanner instructionSectionPlanner, + IEngineVersionProvider engineVersionProvider) + : IRepositoryInitializer +{ + private const string GitIgnoreFileName = ".gitignore"; + + private const string FeaturesDir = KitStorePaths.FeaturesDirName + "/"; + + private static readonly string[] InstructionFileNames = ["CLAUDE.md", "AGENTS.md"]; + + private readonly IGitRepositoryLocator _gitRepositoryLocator = Ensure.NotNull(gitRepositoryLocator); + + private readonly IKitStoreFactory _kitStoreFactory = Ensure.NotNull(kitStoreFactory); + + private readonly IShippedTemplateReader _shippedTemplateReader = Ensure.NotNull(shippedTemplateReader); + + private readonly IGitIgnorePlanner _gitIgnorePlanner = Ensure.NotNull(gitIgnorePlanner); + + private readonly IInstructionSectionPlanner _instructionSectionPlanner = Ensure.NotNull(instructionSectionPlanner); + + private readonly IEngineVersionProvider _engineVersionProvider = Ensure.NotNull(engineVersionProvider); + + /// + public async Task InitializeAsync(string path, CancellationToken cancellationToken = default) + { + Ensure.IsNotNullOrWhitespace(path); + cancellationToken.ThrowIfCancellationRequested(); + + var repositoryRootPath = _gitRepositoryLocator.GetRepositoryRootPath(path); + + // The store creates the kit root with its first file. + var kitStore = _kitStoreFactory.CreateForRepositoryRoot(repositoryRootPath); + + if (File.Exists(kitStore.RootPath)) + { + throw new KitRootNotADirectoryException(kitStore.RootPath); + } + + if (Directory.Exists(kitStore.RootPath)) + { + throw new KitAlreadyInitializedException(kitStore.RootPath); + } + + // Planning reads and checks every repository file, so an invalid file stops the initialisation before + // anything is written. + var repositoryFileChanges = await PlanRepositoryFilesAsync(repositoryRootPath, cancellationToken) + .ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + var files = new List(); + + try + { + await WriteKitRootAsync(kitStore, files, cancellationToken).ConfigureAwait(false); + await WriteRepositoryFilesAsync(repositoryRootPath, repositoryFileChanges, files, cancellationToken) + .ConfigureAwait(false); + } + catch + { + // A kit root left behind would block the next init. It did not exist before this run, so removing it + // loses nothing; the repository files that are already written are recognised by the next planning. + DeleteKitRoot(kitStore.RootPath); + throw; + } + + return new RepositoryInitResult(repositoryRootPath, files); + } + + private static void DeleteKitRoot(string kitRootPath) + { + try + { + Directory.Delete(kitRootPath, recursive: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // The kit root stays in place; the next init then reports it as already initialised. + } + } + + /// + /// Computes the changes of .gitignore, CLAUDE.md, and AGENTS.md, in this order. + /// + /// The name and change of every file that needs a change. + private async Task> PlanRepositoryFilesAsync( + string repositoryRootPath, CancellationToken cancellationToken) + { + var changes = new List<(string FileName, PlannedFileChange Change)>(); + var plannedFilePaths = new HashSet(StringComparer.Ordinal); + + await PlanFileAsync(GitIgnoreFileName, _gitIgnorePlanner.PlanAsync).ConfigureAwait(false); + + foreach (var fileName in InstructionFileNames) + { + await PlanFileAsync(fileName, _instructionSectionPlanner.PlanAsync).ConfigureAwait(false); + } + + return changes; + + async Task PlanFileAsync(string fileName, + Func> planAsync) + { + var filePath = Path.Combine(repositoryRootPath, fileName); + + // A link to a file that is already planned, for example AGENTS.md linked to CLAUDE.md, is that file: its + // change is already planned and would otherwise be reported twice. + if (!plannedFilePaths.Add(ResolveFinalPath(filePath))) + { + return; + } + + if (await planAsync(filePath, cancellationToken).ConfigureAwait(false) is { } change) + { + changes.Add((fileName, change)); + } + } + } + + private static string ResolveFinalPath(string filePath) + { + // ResolveLinkTarget throws for a missing path and returns null for a path that is not a link. + return Path.Exists(filePath) + ? File.ResolveLinkTarget(filePath, returnFinalTarget: true)?.FullName ?? filePath + : filePath; + } + + private async Task WriteKitRootAsync(IKitStore kitStore, List files, + CancellationToken cancellationToken) + { + var kitVersion = _engineVersionProvider.Version; + + // Every other setting keeps the default of KitConfig. + var config = new KitConfig { KitVersion = kitVersion }; + + await WriteDocumentAsync(KitStorePaths.ConfigFileName, FormatIds.Config, config).ConfigureAwait(false); + + await WriteDocumentAsync(KitStorePaths.GatesFileName, FormatIds.Gates, new GatesConfig()) + .ConfigureAwait(false); + + var templatesLock = await WriteTemplatesAsync(kitStore, kitVersion, files, cancellationToken) + .ConfigureAwait(false); + + await WriteDocumentAsync(KitStorePaths.TemplatesLockFileName, FormatIds.TemplatesLock, templatesLock) + .ConfigureAwait(false); + + kitStore.CreateDirectory(KitStorePaths.FeaturesDirName); + files.Add(CreatedInKitRoot(FeaturesDir)); + + async Task WriteDocumentAsync(string relativePath, string formatId, T document) + where T : VersionedDocument + { + await kitStore.WriteJsonAsync(relativePath, formatId, document, FormatAccess.Write, cancellationToken) + .ConfigureAwait(false); + files.Add(CreatedInKitRoot(relativePath)); + } + } + + /// + /// Writes every shipped template as UTF-8 without byte order mark, in catalog order. + /// + /// The lock with one entry per template, sorted ordinally by path. + private async Task WriteTemplatesAsync(IKitStore kitStore, string kitVersion, + List files, CancellationToken cancellationToken) + { + var lockEntries = new List(); + + foreach (var shippedTemplate in ShippedTemplates.All) + { + var content = Encoding.UTF8.GetBytes(_shippedTemplateReader.ReadContent(shippedTemplate)); + + await kitStore.WriteFileAsync(shippedTemplate.KitPath, content, cancellationToken).ConfigureAwait(false); + files.Add(CreatedInKitRoot(shippedTemplate.KitPath)); + + lockEntries.Add(new TemplatesLockEntry + { + Path = shippedTemplate.RepositoryPath, + KitVersion = kitVersion, + Hash = TemplatesLockEntry.ComputeHash(content) + }); + } + + return new TemplatesLock { Templates = [.. lockEntries.OrderBy(entry => entry.Path, StringComparer.Ordinal)] }; + } + + private static async Task WriteRepositoryFilesAsync(string repositoryRootPath, + List<(string FileName, PlannedFileChange Change)> changes, List files, + CancellationToken cancellationToken) + { + foreach (var (fileName, change) in changes) + { + await File.WriteAllBytesAsync(Path.Combine(repositoryRootPath, fileName), change.Content, + cancellationToken).ConfigureAwait(false); + files.Add(new InitializedFile(fileName, change.Action)); + } + } + + private static InitializedFile CreatedInKitRoot(string relativePath) + { + return new InitializedFile(KitStorePaths.GetRepositoryPath(relativePath), InitializedFileAction.Created); + } +} diff --git a/src/CreativeCoders.ShadeKit.Core/ShadeKitCoreServiceCollectionExtensions.cs b/src/CreativeCoders.ShadeKit.Core/ShadeKitCoreServiceCollectionExtensions.cs index d707870..99004e5 100644 --- a/src/CreativeCoders.ShadeKit.Core/ShadeKitCoreServiceCollectionExtensions.cs +++ b/src/CreativeCoders.ShadeKit.Core/ShadeKitCoreServiceCollectionExtensions.cs @@ -1,7 +1,9 @@ using CreativeCoders.Core; using CreativeCoders.ShadeKit.Core.Engine; using CreativeCoders.ShadeKit.Core.Formats; +using CreativeCoders.ShadeKit.Core.Init; using CreativeCoders.ShadeKit.Core.Store; +using CreativeCoders.ShadeKit.Core.Templates; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -13,14 +15,17 @@ namespace CreativeCoders.ShadeKit.Core; public static class ShadeKitCoreServiceCollectionExtensions { /// - /// Registers the SHADE kit core services, such as the , in the specified service collection. + /// Registers the SHADE kit core services, such as the and the + /// , in the specified service collection. /// /// - /// , (with the file types of - /// ), , and - /// are registered as singletons. Existing registrations are kept, so calling - /// this method more than once has no additional effect, and a registration made before the call replaces the - /// default. + /// , , (with the + /// file types of ), , + /// , , + /// , , and + /// are registered as singletons. Existing registrations are kept, so + /// calling this method more than once has no additional effect, and a registration made before the call replaces + /// the default. /// /// The service collection to add the services to. /// The same instance so that additional calls can be chained. @@ -30,9 +35,14 @@ public static IServiceCollection AddShadeKitCore(this IServiceCollection service Ensure.NotNull(services); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(_ => FormatRegistry.CreateDefault()); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/src/CreativeCoders.ShadeKit.Core/Store/FeaturesDirectoryNotFoundException.cs b/src/CreativeCoders.ShadeKit.Core/Store/FeaturesDirectoryNotFoundException.cs deleted file mode 100644 index d91921b..0000000 --- a/src/CreativeCoders.ShadeKit.Core/Store/FeaturesDirectoryNotFoundException.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace CreativeCoders.ShadeKit.Core.Store; - -/// -/// Represents the exception that is thrown when the SHADE kit store contains no features directory. -/// -/// The path of the features directory that was not found. -public class FeaturesDirectoryNotFoundException(string featuresPath) - : Exception($"The features directory '{featuresPath}' was not found.") -{ - /// - /// Gets the path of the features directory that was not found. - /// - /// The full path of the missing features directory. - public string FeaturesPath { get; } = featuresPath; -} diff --git a/src/CreativeCoders.ShadeKit.Core/Store/GitRepositoryLocator.cs b/src/CreativeCoders.ShadeKit.Core/Store/GitRepositoryLocator.cs new file mode 100644 index 0000000..2464766 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Store/GitRepositoryLocator.cs @@ -0,0 +1,29 @@ +using CreativeCoders.Core; + +namespace CreativeCoders.ShadeKit.Core.Store; + +/// +/// Locates the root directory of a git repository in the file system. +/// +public class GitRepositoryLocator : IGitRepositoryLocator +{ + private const string GitEntryName = ".git"; + + /// + public string GetRepositoryRootPath(string path) + { + Ensure.IsNotNullOrWhitespace(path); + + var startPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + + for (var directory = new DirectoryInfo(startPath); directory is not null; directory = directory.Parent) + { + if (Path.Exists(Path.Combine(directory.FullName, GitEntryName))) + { + return Path.TrimEndingDirectorySeparator(directory.FullName); + } + } + + throw new NotAGitRepositoryException(path); + } +} diff --git a/src/CreativeCoders.ShadeKit.Core/Store/IGitRepositoryLocator.cs b/src/CreativeCoders.ShadeKit.Core/Store/IGitRepositoryLocator.cs new file mode 100644 index 0000000..140454e --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Store/IGitRepositoryLocator.cs @@ -0,0 +1,28 @@ +namespace CreativeCoders.ShadeKit.Core.Store; + +/// +/// Locates the root directory of the git repository that contains a given directory. +/// +public interface IGitRepositoryLocator +{ + /// + /// Gets the root directory of the git repository that contains the specified directory. + /// + /// + /// The search walks up from and stops at the closest directory that contains a + /// .git directory or file (a worktree or submodule), so the root of a nested repository wins over the root + /// of the repository around it. A relative is resolved against the current directory. + /// + /// The directory at which the search starts. + /// + /// The full path of the repository root, without a trailing directory separator unless the repository root is a + /// file system root such as / or C:\. + /// + /// + /// is , empty, or whitespace. + /// + /// + /// is not inside a git repository. + /// + string GetRepositoryRootPath(string path); +} diff --git a/src/CreativeCoders.ShadeKit.Core/Store/IKitStore.cs b/src/CreativeCoders.ShadeKit.Core/Store/IKitStore.cs index f057256..7b8828b 100644 --- a/src/CreativeCoders.ShadeKit.Core/Store/IKitStore.cs +++ b/src/CreativeCoders.ShadeKit.Core/Store/IKitStore.cs @@ -17,8 +17,10 @@ public interface IKitStore /// /// Gets the names of all features in the SHADE kit store. /// - /// The names of the directories in the features directory of the store. - /// The store contains no features directory. + /// + /// The names of the directories in the features directory of the store, or an empty sequence if the store + /// contains no features directory. + /// IEnumerable GetFeatureNames(); /// @@ -107,4 +109,34 @@ public interface IKitStore Task WriteJsonAsync(string relativePath, string formatId, T document, FormatAccess access, CancellationToken cancellationToken = default) where T : VersionedDocument; + + /// + /// Writes a file of the store that is not a versioned JSON file, for example a template. + /// + /// + /// The bytes are written as they are, directly, not atomically; an existing file is replaced. Missing directories + /// are created. + /// + /// + /// The path of the file relative to , for example templates/spec.md. + /// + /// The bytes of the file. + /// The token to monitor for cancellation requests. + /// A task that represents the asynchronous write operation. + /// is . + /// + /// is empty, rooted, or points outside . + /// + Task WriteFileAsync(string relativePath, byte[] content, CancellationToken cancellationToken = default); + + /// + /// Creates a directory of the store, together with its missing parent directories. + /// + /// + /// The path of the directory relative to , for example features. + /// + /// + /// is empty, rooted, or points outside . + /// + void CreateDirectory(string relativePath); } diff --git a/src/CreativeCoders.ShadeKit.Core/Store/IKitStoreFactory.cs b/src/CreativeCoders.ShadeKit.Core/Store/IKitStoreFactory.cs index d62759f..ee7265d 100644 --- a/src/CreativeCoders.ShadeKit.Core/Store/IKitStoreFactory.cs +++ b/src/CreativeCoders.ShadeKit.Core/Store/IKitStoreFactory.cs @@ -1,13 +1,14 @@ namespace CreativeCoders.ShadeKit.Core.Store; /// -/// Creates instances for the SHADE kit root of the git repository that contains a given -/// directory. +/// Creates instances for the SHADE kit root of a git repository. /// /// -/// The search walks up from the given directory to the root of the git repository, the closest directory that -/// contains a .git directory or file (a worktree or submodule). Only a .shadekit directory at that -/// repository root is a SHADE kit root; .shadekit directories below it are ignored. +/// and search for the root of the git repository that contains a +/// directory with : the closest directory that contains a .git directory +/// or file (a worktree or submodule). Only a .shadekit directory at that repository root is a SHADE kit root; +/// .shadekit directories below it are ignored. skips the search for +/// callers that already know the repository root, for example while the SHADE kit root is being created. /// public interface IKitStoreFactory { @@ -17,6 +18,7 @@ public interface IKitStoreFactory /// The store of the .shadekit directory at the repository root. /// The current directory is not inside a git repository. /// The repository root contains no .shadekit directory. + /// The repository root contains a .shadekit file. IKitStore Create(); /// @@ -27,5 +29,20 @@ public interface IKitStoreFactory /// is , empty, or whitespace. /// is not inside a git repository. /// The repository root contains no .shadekit directory. + /// The repository root contains a .shadekit file. IKitStore Create(string path); + + /// + /// Creates a store for the SHADE kit root of the specified repository root directory. + /// + /// + /// Neither searches for a git repository nor checks that the SHADE kit root exists. A store for a directory that + /// does not exist yet creates the missing directories when it writes a file. + /// + /// The path of the root directory of the git repository. + /// The store of the .shadekit directory in . + /// + /// is , empty, or whitespace. + /// + IKitStore CreateForRepositoryRoot(string repositoryRootPath); } diff --git a/src/CreativeCoders.ShadeKit.Core/Store/KitRootNotADirectoryException.cs b/src/CreativeCoders.ShadeKit.Core/Store/KitRootNotADirectoryException.cs new file mode 100644 index 0000000..f45c794 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Store/KitRootNotADirectoryException.cs @@ -0,0 +1,16 @@ +namespace CreativeCoders.ShadeKit.Core.Store; + +/// +/// Represents the exception that is thrown when the SHADE kit root path (.shadekit) at the root of a git +/// repository is a file instead of a directory. +/// +/// The path of the .shadekit file. +public class KitRootNotADirectoryException(string kitRootPath) + : Exception($"The SHADE kit root '{kitRootPath}' is a file, not a directory.") +{ + /// + /// Gets the path of the SHADE kit root that is a file. + /// + /// The full path of the .shadekit file at the repository root. + public string KitRootPath { get; } = kitRootPath; +} diff --git a/src/CreativeCoders.ShadeKit.Core/Store/KitStore.cs b/src/CreativeCoders.ShadeKit.Core/Store/KitStore.cs index 7702130..5f513ef 100644 --- a/src/CreativeCoders.ShadeKit.Core/Store/KitStore.cs +++ b/src/CreativeCoders.ShadeKit.Core/Store/KitStore.cs @@ -47,7 +47,7 @@ public IEnumerable GetFeatureNames() if (!Directory.Exists(featuresPath)) { - throw new FeaturesDirectoryNotFoundException(featuresPath); + return []; } return @@ -114,12 +114,28 @@ await ReadFileAsync(relativePath, formatId, access == FormatAccess.Upgrade, c .ConfigureAwait(false); var json = _serializer.Serialize(document, formatId); + + await WriteFileAsync(relativePath, Utf8WithoutBom.GetBytes(json), cancellationToken).ConfigureAwait(false); + } + + /// + public async Task WriteFileAsync(string relativePath, byte[] content, + CancellationToken cancellationToken = default) + { + Ensure.NotNull(content); + var fullPath = GetFullPath(relativePath); // GetFullPath only returns paths below RootPath, so the file always has a parent directory. Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); - await File.WriteAllTextAsync(fullPath, json, Utf8WithoutBom, cancellationToken).ConfigureAwait(false); + await File.WriteAllBytesAsync(fullPath, content, cancellationToken).ConfigureAwait(false); + } + + /// + public void CreateDirectory(string relativePath) + { + Directory.CreateDirectory(GetFullPath(relativePath)); } private async Task?> ReadFileAsync(string relativePath, string formatId, @@ -184,7 +200,7 @@ private string GetFullPath(string relativePath) private string GetDisplayPath(string relativePath) { - return $"{KitStorePaths.RootPath}/{Path.GetRelativePath(RootPath, GetFullPath(relativePath))}" - .Replace(Path.DirectorySeparatorChar, '/'); + return KitStorePaths.GetRepositoryPath(Path.GetRelativePath(RootPath, GetFullPath(relativePath)) + .Replace(Path.DirectorySeparatorChar, '/')); } } diff --git a/src/CreativeCoders.ShadeKit.Core/Store/KitStoreFactory.cs b/src/CreativeCoders.ShadeKit.Core/Store/KitStoreFactory.cs index d5cfd7d..da6f737 100644 --- a/src/CreativeCoders.ShadeKit.Core/Store/KitStoreFactory.cs +++ b/src/CreativeCoders.ShadeKit.Core/Store/KitStoreFactory.cs @@ -6,11 +6,16 @@ namespace CreativeCoders.ShadeKit.Core.Store; /// /// Creates instances for the SHADE kit root at the root of a git repository. /// +/// The locator that finds the root of the git repository that contains a directory. /// The serializer that the created stores use for versioned JSON files. /// The registry that defines the file types the created stores accept. -public class KitStoreFactory(IVersionedJsonSerializer serializer, IFormatRegistry formatRegistry) : IKitStoreFactory +public class KitStoreFactory( + IGitRepositoryLocator gitRepositoryLocator, + IVersionedJsonSerializer serializer, + IFormatRegistry formatRegistry) + : IKitStoreFactory { - private const string GitEntryName = ".git"; + private readonly IGitRepositoryLocator _gitRepositoryLocator = Ensure.NotNull(gitRepositoryLocator); private readonly IVersionedJsonSerializer _serializer = Ensure.NotNull(serializer); @@ -27,28 +32,24 @@ public IKitStore Create(string path) { Ensure.IsNotNullOrWhitespace(path); - var gitRootPath = FindGitRootPath(Path.TrimEndingDirectorySeparator(Path.GetFullPath(path))) - ?? throw new NotAGitRepositoryException(path); + var gitRootPath = _gitRepositoryLocator.GetRepositoryRootPath(path); + var kitStore = CreateForRepositoryRoot(gitRootPath); - var kitStorePath = Path.Combine(gitRootPath, KitStorePaths.RootPath); + if (File.Exists(kitStore.RootPath)) + { + throw new KitRootNotADirectoryException(kitStore.RootPath); + } - return Directory.Exists(kitStorePath) - ? new KitStore(kitStorePath, _serializer, _formatRegistry) + return Directory.Exists(kitStore.RootPath) + ? kitStore : throw new KitRootPathNotFoundException(path, gitRootPath); } - private static string? FindGitRootPath(string startPath) + /// + public IKitStore CreateForRepositoryRoot(string repositoryRootPath) { - for (var directory = new DirectoryInfo(startPath); directory is not null; directory = directory.Parent) - { - var gitEntryPath = Path.Combine(directory.FullName, GitEntryName); - - if (Path.Exists(gitEntryPath)) - { - return directory.FullName; - } - } + Ensure.IsNotNullOrWhitespace(repositoryRootPath); - return null; + return new KitStore(Path.Combine(repositoryRootPath, KitStorePaths.RootPath), _serializer, _formatRegistry); } } diff --git a/src/CreativeCoders.ShadeKit.Core/Store/KitStorePaths.cs b/src/CreativeCoders.ShadeKit.Core/Store/KitStorePaths.cs index 9a66407..e797245 100644 --- a/src/CreativeCoders.ShadeKit.Core/Store/KitStorePaths.cs +++ b/src/CreativeCoders.ShadeKit.Core/Store/KitStorePaths.cs @@ -19,4 +19,48 @@ public static class KitStorePaths /// Represents the name of the directory that contains one directory per feature. /// public const string FeaturesDirName = "features"; + + /// + /// Represents the name of the file in the directory that records the current + /// feature of a local clone. The file is local-only and not committed. + /// + public const string CurrentFeatureFileName = "current-feature.json"; + + /// + /// Represents the name of the gate configuration file in the SHADE kit root directory. + /// + public const string GatesFileName = "gates.json"; + + /// + /// Represents the name of the local configuration file in the SHADE kit root directory. The file is local-only + /// and not committed. + /// + public const string LocalFileName = "local.json"; + + /// + /// Represents the name of the directory in the SHADE kit root directory that contains the templates of the + /// repository. + /// + public const string TemplatesDirName = "templates"; + + /// + /// Represents the name of the file in the SHADE kit root directory that records the kit version and hash of every + /// template written by SHADE kit. + /// + public const string TemplatesLockFileName = "templates.lock.json"; + + /// + /// Gets the path relative to the repository root of a path relative to the SHADE kit root directory. + /// + /// + /// The path relative to the SHADE kit root directory, with / as separator, for example + /// templates/spec.md. + /// + /// + /// The path prefixed with and /, for example .shadekit/templates/spec.md. + /// + public static string GetRepositoryPath(string kitRelativePath) + { + return $"{RootPath}/{kitRelativePath}"; + } } diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/IShippedTemplateReader.cs b/src/CreativeCoders.ShadeKit.Core/Templates/IShippedTemplateReader.cs new file mode 100644 index 0000000..c1bd681 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/IShippedTemplateReader.cs @@ -0,0 +1,24 @@ +namespace CreativeCoders.ShadeKit.Core.Templates; + +/// +/// Reads the content of the templates that ship with SHADE kit. +/// +public interface IShippedTemplateReader +{ + /// + /// Reads the content of a shipped template. + /// + /// The shipped template to read. + /// + /// The content of the template, with every line ending replaced by LF, as + /// recognises them. + /// + /// + /// is . + /// + /// + /// The SHADE kit engine contains no embedded resource for the of + /// . + /// + string ReadContent(ShippedTemplate shippedTemplate); +} diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/Resources/checks/definition-of-done.template.md b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/checks/definition-of-done.template.md new file mode 100644 index 0000000..bda04c9 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/checks/definition-of-done.template.md @@ -0,0 +1,33 @@ +# Definition of Done: {{feature-id}} + + + +Feature: `{{feature-id}}` · Spec: [spec.md](../spec.md) · Tasks: [tasks.md](../tasks.md) · Verification: [verification.md](../verification.md) + +| ID | Criterion | Result | Evidence | +|---|---|---|---| +| DOD-01 | Build, test, and lint pass with the quality commands recorded in `review.md`, as recorded in `verification.md`. | pending | | +| DOD-02 | Coverage was measured and recorded in `verification.md`. If `config.json` sets `coverageThreshold`, the value reaches it. | pending | | +| DOD-03 | Every acceptance criterion is covered by a passing test, or is marked manual and has an item in `playbook.md`. | pending | | +| DOD-04 | Every task in `tasks.md` is done, or has a "Not done" reason that the human accepted. | pending | | +| DOD-05 | `review.md` has no open blocker. | pending | | +| DOD-06 | The quality bar was not lowered: no new analyzer or lint suppressions, skipped or deleted tests, or weakened assertions without a reason accepted in `review.md`. | pending | | +| DOD-07 | Every deviation from the plan is recorded in `plan.md` with a reason, and every migration or rollout step is done or handed over. | pending | | +| DOD-08 | The documentation matches the change (for example README, changelog, API docs), or Evidence states why no update is needed. | pending | | +| DOD-09 | Every item in `playbook.md` is checked off by a human. | pending | | + +## Check runs + + diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/Resources/checks/definition-of-ready.template.md b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/checks/definition-of-ready.template.md new file mode 100644 index 0000000..b5d6467 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/checks/definition-of-ready.template.md @@ -0,0 +1,33 @@ +# Definition of Ready: {{feature-id}} + + + +Feature: `{{feature-id}}` · Spec: [spec.md](../spec.md) + +| ID | Criterion | Result | Evidence | +|---|---|---|---| +| DOR-01 | Every requirement in `spec.md` has an ID (`REQ-nnn`) and at least one acceptance criterion. | pending | | +| DOR-02 | Every acceptance criterion can be decided by an automated test, or is marked "(manual)" with a reason. | pending | | +| DOR-03 | Every requirement has a priority: must, should, or could. | pending | | +| DOR-04 | `spec.md` has no open questions: "Open questions" says "None." and no `(open: Q-nnn)` marker is left. | pending | | +| DOR-05 | `spec.md` has no template leftovers: no `{{placeholder}}` and no guidance comment. | pending | | +| DOR-06 | "Out of scope" is stated, also when nothing is excluded. | pending | | +| DOR-07 | Every assumption is confirmed by a human or marked as a risk. | pending | | +| DOR-08 | Every edge case has an acceptance criterion or is listed in "Out of scope". | pending | | +| DOR-09 | "Security considerations" lists the findings, or says "None identified." with a reason. | pending | | +| DOR-10 | The affected areas are known: `map` is done, was skipped on purpose with a recorded reason, or `diagnosis.md` lists the affected code. | pending | | + +## Check runs + + diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/Resources/diagnosis.template.md b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/diagnosis.template.md new file mode 100644 index 0000000..df69ad2 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/diagnosis.template.md @@ -0,0 +1,38 @@ +# Diagnosis: {{feature-title}} + + + +Feature: `{{feature-id}}` · Request: [request.md](request.md) · Spec: [spec.md](spec.md) · Research: [researches.md](researches.md) + +## Reproduction + +{{steps and environment}} + +## Observed and expected behaviour + +{{observed behaviour versus expected behaviour}} + +## Root cause + +{{root cause with file and line evidence}} + +## Ruled-out causes + +{{causes checked and ruled out}} + +## Affected code + +{{affected files, types, and tests}} + +## Fix options + +{{fix options with a recommendation}} diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/Resources/discussion.template.md b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/discussion.template.md new file mode 100644 index 0000000..58204c5 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/discussion.template.md @@ -0,0 +1,30 @@ +# Discussion: {{topic}} + + + +Feature: `{{feature-id | none}}` · Date: {{date}} · Participants: {{participants}} + +## Context + +{{context}} + +## Options + +{{options discussed}} + +## Conclusions + +{{conclusions}} + +## Open questions + +{{open questions}} diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/Resources/plan.template.md b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/plan.template.md new file mode 100644 index 0000000..67cd131 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/plan.template.md @@ -0,0 +1,130 @@ +# Plan: {{feature-title}} + + + +Feature: `{{feature-id}}` · Spec: [spec.md](spec.md) · Research: [researches.md](researches.md) · Tasks: [tasks.md](tasks.md) + +## Summary + + + +{{summary}} + +## Technical context + + + +- Language and runtime: {{language and version}} +- Frameworks and libraries: {{frameworks}} +- Storage: {{storage | n/a}} +- Test frameworks: {{test frameworks}} +- Target platforms: {{platforms}} +- Constraints: {{performance, compatibility, or platform constraints | n/a}} + +## Approach + + + +{{approach}} + +## Affected components + + + +| Component or path | Change | Requirements | +|---|---|---| +| {{component or path}} | {{new \| changed \| removed}} | {{REQ-nnn}} | + +## Design decisions + + + +### D-001 – {{decision-title}} + +- Context: {{context}} +- Decision: {{decision}} +- Rejected alternatives: + - {{alternative}} – {{reason for rejection}} +- Consequences: {{consequences}} + +## Test strategy + + + +| Level | Scope | Location | +|---|---|---| +| unit | {{what is tested at this level}} | {{test project or path}} | +| integration | {{what is tested at this level \| n/a}} | {{test project or path}} | +| manual | Criteria marked "(manual)" in spec.md | `playbook.md` | + +Quality commands: + +| Check | Command | +|---|---| +| Build | `{{build command}}` | +| Test | `{{test command}}` | +| Lint | `{{lint command}}` | +| Coverage | `{{coverage command}}` | + +## Migration and rollout + + + +- {{migration or rollout step}} + +## Risks + + + +| Risk | Impact | Mitigation | +|---|---|---| +| {{risk}} | {{impact}} | {{mitigation}} | + +## Deviations + + + +- None. diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/Resources/playbook.template.md b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/playbook.template.md new file mode 100644 index 0000000..1ff73f7 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/playbook.template.md @@ -0,0 +1,19 @@ +# Playbook: {{feature-title}} + + + +Feature: `{{feature-id}}` · Spec: [spec.md](spec.md) · Verification: [verification.md](verification.md) + +## Manual acceptance criteria + +{{one checkbox item per manual acceptance criterion}} + +## Behaviour checks + +{{one checkbox item per behaviour to see working}} diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/Resources/report.template.md b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/report.template.md new file mode 100644 index 0000000..8c03f2c --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/report.template.md @@ -0,0 +1,42 @@ +# Report: {{feature-title}} + + + +Feature: `{{feature-id}}` · Spec: [spec.md](spec.md) · Tasks: [tasks.md](tasks.md) · Review: [review.md](review.md) · Verification: [verification.md](verification.md) · Playbook: [playbook.md](playbook.md) · Definition of Done: [definition-of-done.md](checks/definition-of-done.md) + +## Summary + +{{goal from spec.md, profile, start date, and finish date}} + +## Requirements and tests + +{{requirement → acceptance criteria → proving tests → result}} + +## Checks + +{{build, test, lint, and coverage results from verification.md}} + +## Review + +{{findings per severity and accepted findings}} + +## Manual checks + +{{playbook items and whether each is checked off}} + +## Deviations + +{{deviations recorded in plan.md}} + +## Approvals + +{{every transition from the history}} diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/Resources/request.template.md b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/request.template.md new file mode 100644 index 0000000..96ef3c9 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/request.template.md @@ -0,0 +1,25 @@ +# Request: {{feature-title}} + + + +Feature: `{{feature-id}}` · Spec: [spec.md](spec.md) + +## Source + +{{source kind and source reference}} + +## Original input + +{{original input, unchanged}} + +## Created + +{{created at and created by}} diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/Resources/researches.template.md b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/researches.template.md new file mode 100644 index 0000000..43ec189 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/researches.template.md @@ -0,0 +1,46 @@ +# Research: {{feature-title}} + + + +Feature: `{{feature-id}}` · Request: [request.md](request.md) · Spec: [spec.md](spec.md) + +## refine / gaps + +{{date, findings, and sources}} + +## refine / security + +{{date, findings, and sources}} + +## map / best practice + +{{date, findings, and sources}} + +## map / existing architecture + +{{date, findings, and sources}} + +## map / existing affected code + +{{date, findings, and sources}} + +## map / don't hand-roll + +{{date, findings, and sources}} + +## map / security at code level + +{{date, findings, and sources}} + +## map / skipped + +{{date, reason, and git user}} diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/Resources/review.template.md b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/review.template.md new file mode 100644 index 0000000..3bd675f --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/review.template.md @@ -0,0 +1,26 @@ +# Review: {{feature-title}} + + + +Feature: `{{feature-id}}` · Spec: [spec.md](spec.md) · Plan: [plan.md](plan.md) · Tasks: [tasks.md](tasks.md) + +## Rounds + +{{one entry per round}} + +## Findings + +{{one entry per finding}} + +## Commands + +{{build, test, and lint commands used}} diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/Resources/spec.template.md b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/spec.template.md new file mode 100644 index 0000000..3d2c8e0 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/spec.template.md @@ -0,0 +1,124 @@ +# Spec: {{feature-title}} + + + +Feature: `{{feature-id}}` · Request: [request.md](request.md) · Research: [researches.md](researches.md) + +## Goal + + + +{{goal}} + +## Glossary + + + +| Term | Kind | Meaning | +|---|---|---| +| {{term}} | {{term \| entity}} | {{meaning}} | + +## Requirements + + + +### REQ-001 – {{requirement-title}} + +- Priority: {{must | should | could}} +- Status: active +- Rationale: {{why this is needed}} + +{{requirement statement}} + +Acceptance criteria: + +- **AC-001.1** {{criterion}} + +## Edge cases + + + +- {{edge case}} → {{AC-nnn.n | out of scope}} + +## Security considerations + + + +- {{finding}} → {{REQ-nnn | AC-nnn.n}} + +## Out of scope + + + +- {{excluded item}} + +## Assumptions + + + +- **A-001** {{assumption}} ({{unconfirmed | confirmed | risk}}) + +## Open questions + + + +- **Q-001** ({{REQ-nnn}}) {{question}} + +## Decisions + + + +- **Q-nnn** ({{REQ-nnn}}) {{question}} → {{answer}} ({{decided by}}, {{date}}) diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/Resources/tasks.template.md b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/tasks.template.md new file mode 100644 index 0000000..56e651d --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/tasks.template.md @@ -0,0 +1,57 @@ +# Tasks: {{feature-title}} + + + +Feature: `{{feature-id}}` · Spec: [spec.md](spec.md) · Plan: [plan.md](plan.md) + +## Tasks + + + +- [ ] **T-001** – {{task-title}} + - Requirements: {{REQ-nnn}} + - Depends on: {{T-nnn | none}} + - Parallel: {{yes | no}} + - Affected: {{paths or components}} + - Proving tests: + - [ ] `{{test name}}` → {{AC-nnn.n}} + - Notes: {{– | deviations, reason why not done}} + +## Coverage matrix + + + +| Criterion | Requirement | Proving tests | Tasks | Manual | +|---|---|---|---|---| +| {{AC-nnn.n}} | {{REQ-nnn}} | {{test names}} | {{T-nnn}} | {{no \| yes – reason}} | diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/Resources/verification.template.md b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/verification.template.md new file mode 100644 index 0000000..b11e3b2 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/Resources/verification.template.md @@ -0,0 +1,28 @@ +# Verification: {{feature-title}} + + + +Feature: `{{feature-id}}` · Spec: [spec.md](spec.md) · Tasks: [tasks.md](tasks.md) · Review: [review.md](review.md) · Playbook: [playbook.md](playbook.md) + +## Run + +{{date of the run}} + +## Commands + +{{commands with their results}} + +## Coverage + +{{coverage value}} + +## Acceptance criteria + +{{acceptance criterion → proving tests → result}} diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/ShippedTemplate.cs b/src/CreativeCoders.ShadeKit.Core/Templates/ShippedTemplate.cs new file mode 100644 index 0000000..47b6363 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/ShippedTemplate.cs @@ -0,0 +1,26 @@ +using CreativeCoders.ShadeKit.Core.Store; + +namespace CreativeCoders.ShadeKit.Core.Templates; + +/// +/// Represents a template that ships with SHADE kit and is copied into a repository by shade-kit init. +/// +/// +/// The path of the template source relative to the shipped template folder, with / as separator, for example +/// checks/definition-of-ready.template.md. +/// +/// +/// The path the template is written to, relative to the SHADE kit root directory, with / as separator, for +/// example templates/checks/definition-of-ready.md. +/// +public sealed record ShippedTemplate(string ResourcePath, string KitPath) +{ + /// + /// Gets the path the template is written to, relative to the repository root. + /// + /// + /// The prefixed with the SHADE kit root directory, for example + /// .shadekit/templates/checks/definition-of-ready.md. + /// + public string RepositoryPath => KitStorePaths.GetRepositoryPath(KitPath); +} diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/ShippedTemplateReader.cs b/src/CreativeCoders.ShadeKit.Core/Templates/ShippedTemplateReader.cs new file mode 100644 index 0000000..4463a10 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/ShippedTemplateReader.cs @@ -0,0 +1,54 @@ +using System.Text; +using CreativeCoders.Core; + +namespace CreativeCoders.ShadeKit.Core.Templates; + +/// +/// Reads shipped templates from the resources embedded in the SHADE kit core assembly. +/// +/// +/// A template source is embedded under the name templates/ followed by its +/// . The name is the same on every operating system, and embedded +/// resources are also available in a self-contained single-file build. +/// +public sealed class ShippedTemplateReader : IShippedTemplateReader +{ + internal const string ResourceNamePrefix = "templates/"; + + private readonly Func _openResource; + + /// + /// Initializes a new instance of the class that reads the resources of the + /// SHADE kit core assembly. + /// + public ShippedTemplateReader() + : this(typeof(ShippedTemplateReader).Assembly.GetManifestResourceStream) + { + } + + internal ShippedTemplateReader(Func openResource) + { + _openResource = Ensure.NotNull(openResource); + } + + /// + public string ReadContent(ShippedTemplate shippedTemplate) + { + Ensure.NotNull(shippedTemplate); + + var resourceName = GetResourceName(shippedTemplate); + + using var stream = _openResource(resourceName) + ?? throw new InvalidOperationException( + $"The shipped template resource '{resourceName}' is missing from the SHADE kit engine."); + + using var reader = new StreamReader(stream, Encoding.UTF8); + + return reader.ReadToEnd().ReplaceLineEndings("\n"); + } + + internal static string GetResourceName(ShippedTemplate shippedTemplate) + { + return ResourceNamePrefix + shippedTemplate.ResourcePath; + } +} diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/ShippedTemplates.cs b/src/CreativeCoders.ShadeKit.Core/Templates/ShippedTemplates.cs new file mode 100644 index 0000000..6d6be96 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/ShippedTemplates.cs @@ -0,0 +1,37 @@ +using CreativeCoders.ShadeKit.Core.Store; + +namespace CreativeCoders.ShadeKit.Core.Templates; + +/// +/// Provides the catalog of templates that ship with SHADE kit. +/// +/// +/// The catalog is an explicit list: repository paths are not derived from the names of the template sources. Every +/// entry has exactly one embedded template resource in the SHADE kit core assembly, and every embedded template +/// resource has exactly one entry. +/// +public static class ShippedTemplates +{ + private const string TemplatesDir = KitStorePaths.TemplatesDirName + "/"; + + /// + /// Gets every template that ships with SHADE kit. + /// + /// The shipped templates, each with its source path and its path in the SHADE kit root. + public static IReadOnlyList All { get; } = + [ + new("request.template.md", TemplatesDir + "request.md"), + new("discussion.template.md", TemplatesDir + "discussion.md"), + new("spec.template.md", TemplatesDir + "spec.md"), + new("researches.template.md", TemplatesDir + "researches.md"), + new("plan.template.md", TemplatesDir + "plan.md"), + new("tasks.template.md", TemplatesDir + "tasks.md"), + new("review.template.md", TemplatesDir + "review.md"), + new("verification.template.md", TemplatesDir + "verification.md"), + new("playbook.template.md", TemplatesDir + "playbook.md"), + new("report.template.md", TemplatesDir + "report.md"), + new("diagnosis.template.md", TemplatesDir + "diagnosis.md"), + new("checks/definition-of-ready.template.md", TemplatesDir + "checks/definition-of-ready.md"), + new("checks/definition-of-done.template.md", TemplatesDir + "checks/definition-of-done.md") + ]; +} diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/TemplatesLock.cs b/src/CreativeCoders.ShadeKit.Core/Templates/TemplatesLock.cs new file mode 100644 index 0000000..dc6e992 --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/TemplatesLock.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using CreativeCoders.ShadeKit.Core.Formats; + +namespace CreativeCoders.ShadeKit.Core.Templates; + +/// +/// Represents the record of the shipped templates installed in a repository, stored in +/// .shadekit/templates.lock.json. +/// +/// +/// shade-kit init writes one entry per shipped template; shade-kit upgrade uses the entries to tell +/// templates the team changed from unchanged ones. Fields the engine does not know are kept in +/// and written back unchanged. +/// +public sealed class TemplatesLock : VersionedDocument, IJsonOnDeserialized +{ + /// + /// Gets or sets the installed templates. + /// + /// + /// One entry per installed template. The default is an empty list, which also applies when the field is missing. + /// A entry makes the file invalid. + /// + public List Templates { get; set; } = []; + + void IJsonOnDeserialized.OnDeserialized() + { + // RespectNullableAnnotations does not cover list elements, so a null entry is rejected here. + if (Templates.Any(entry => entry is null)) + { + throw new JsonException("The 'templates' field contains a null entry."); + } + } +} diff --git a/src/CreativeCoders.ShadeKit.Core/Templates/TemplatesLockEntry.cs b/src/CreativeCoders.ShadeKit.Core/Templates/TemplatesLockEntry.cs new file mode 100644 index 0000000..9e19e2d --- /dev/null +++ b/src/CreativeCoders.ShadeKit.Core/Templates/TemplatesLockEntry.cs @@ -0,0 +1,63 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CreativeCoders.ShadeKit.Core.Templates; + +/// +/// Represents one installed template in .shadekit/templates.lock.json. +/// +/// +/// , , and are required: a lock file in which one of +/// them is missing or cannot be read. Fields the engine does not know are kept in +/// and written back unchanged. +/// +public sealed class TemplatesLockEntry +{ + private const string HashPrefix = "sha256:"; + + /// + /// Gets the path of the installed template. + /// + /// + /// The path relative to the repository root, with / as separator, for example + /// .shadekit/templates/spec.md. + /// + public required string Path { get; init; } + + /// + /// Gets the kit version that installed the template. + /// + /// A semantic version string. + public required string KitVersion { get; init; } + + /// + /// Gets the hash of the template content as installed. + /// + /// + /// The SHA-256 hash of the bytes written, in the form sha256: followed by 64 lowercase hexadecimal digits. + /// + public required string Hash { get; init; } + + /// + /// Gets or sets the fields of the entry that the engine does not know. + /// + /// + /// The unknown fields and their values, kept as read, or if the entry has none. + /// + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } + + /// + /// Computes the of the content of a template. + /// + /// The bytes of the template as written to the repository. + /// + /// The SHA-256 hash of , in the form sha256: followed by 64 lowercase + /// hexadecimal digits. + /// + public static string ComputeHash(ReadOnlySpan content) + { + return HashPrefix + Convert.ToHexStringLower(SHA256.HashData(content)); + } +} diff --git a/templates/definition-of-done.template.md b/templates/definition-of-done.template.md deleted file mode 100644 index e69de29..0000000 diff --git a/templates/definition-of-ready.md b/templates/definition-of-ready.md deleted file mode 100644 index e69de29..0000000 diff --git a/templates/plan.template.md b/templates/plan.template.md deleted file mode 100644 index e69de29..0000000 diff --git a/templates/spec.template.md b/templates/spec.template.md deleted file mode 100644 index e69de29..0000000 diff --git a/templates/tasks.template.md b/templates/tasks.template.md deleted file mode 100644 index e69de29..0000000 diff --git a/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Feature/List/FeatureListCommandCurrentDirectoryTests.cs b/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Feature/List/FeatureListCommandCurrentDirectoryTests.cs new file mode 100644 index 0000000..4f74b9e --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Feature/List/FeatureListCommandCurrentDirectoryTests.cs @@ -0,0 +1,38 @@ +using AwesomeAssertions; +using CreativeCoders.Cli.Hosting.Exceptions; +using CreativeCoders.ShadeKit.Cli.Commands.Feature.List; +using CreativeCoders.ShadeKit.Core.Store; +using CreativeCoders.ShadeKit.Tests.Shared; +using FakeItEasy; +using Spectre.Console; +using Spectre.Console.Testing; + +namespace CreativeCoders.ShadeKit.Cli.Tests.Commands.Feature.List; + +[Collection(CurrentDirectoryCollectionDefinition.Name)] +public class FeatureListCommandCurrentDirectoryTests +{ + [Fact] + public async Task ExecuteAsync_NotAGitRepositoryInDirectoryWithMarkupCharacters_EscapesAbortMessage() + { + // Arrange + var kitStoreFactory = A.Fake(); + + var command = FeatureListCommandTests.CreateCommand(new TestConsole(), kitStoreFactory); + + using var markupDirectory = new TempKitDirectory("shade-kit-[tests]-"); + using var currentDirectory = new CurrentDirectoryScope(markupDirectory.RootPath); + + A.CallTo(() => kitStoreFactory.Create()) + .Throws(new NotAGitRepositoryException(Directory.GetCurrentDirectory())); + + // Act + var act = () => command.ExecuteAsync(new FeatureListCommandOptions()); + + // Assert + var exception = (await act.Should().ThrowAsync()).Which; + + exception.Message + .Should().Contain(Markup.Escape(Directory.GetCurrentDirectory())); + } +} diff --git a/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Features/List/FeaturesListCommandTests.cs b/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Feature/List/FeatureListCommandTests.cs similarity index 77% rename from tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Features/List/FeaturesListCommandTests.cs rename to tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Feature/List/FeatureListCommandTests.cs index 51f6c8f..dc34b7f 100644 --- a/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Features/List/FeaturesListCommandTests.cs +++ b/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Feature/List/FeatureListCommandTests.cs @@ -4,7 +4,7 @@ using CreativeCoders.Cli.Core; using CreativeCoders.Cli.Hosting.Exceptions; using CreativeCoders.ShadeKit.Cli.Commands; -using CreativeCoders.ShadeKit.Cli.Commands.Features.List; +using CreativeCoders.ShadeKit.Cli.Commands.Feature.List; using CreativeCoders.ShadeKit.Cli.Output; using CreativeCoders.ShadeKit.Cli.Tests.Output; using CreativeCoders.ShadeKit.Core.Store; @@ -12,9 +12,9 @@ using Spectre.Console; using Spectre.Console.Testing; -namespace CreativeCoders.ShadeKit.Cli.Tests.Commands.Features.List; +namespace CreativeCoders.ShadeKit.Cli.Tests.Commands.Feature.List; -public class FeaturesListCommandTests +public class FeatureListCommandTests { [Fact] public async Task ExecuteAsync_FeaturesExist_WritesFeatureNamesInOrdinalOrder() @@ -24,7 +24,7 @@ public async Task ExecuteAsync_FeaturesExist_WritesFeatureNamesInOrdinalOrder() var command = CreateCommand(console, CreateKitStoreFactory("login", "Billing", "auth")); // Act - await command.ExecuteAsync(new FeaturesListCommandOptions()); + await command.ExecuteAsync(new FeatureListCommandOptions()); // Assert console.Lines @@ -38,7 +38,7 @@ public async Task ExecuteAsync_FeaturesExist_ReturnsSuccess() var command = CreateCommand(new TestConsole(), CreateKitStoreFactory("auth")); // Act - var result = await command.ExecuteAsync(new FeaturesListCommandOptions()); + var result = await command.ExecuteAsync(new FeatureListCommandOptions()); // Assert result.ExitCode @@ -53,7 +53,7 @@ public async Task ExecuteAsync_FeatureNameContainsMarkupCharacters_WritesNameVer var command = CreateCommand(console, CreateKitStoreFactory("[red]feature[/]")); // Act - await command.ExecuteAsync(new FeaturesListCommandOptions()); + await command.ExecuteAsync(new FeatureListCommandOptions()); // Assert console.Lines @@ -68,7 +68,7 @@ public async Task ExecuteAsync_NoFeatures_WritesHintAndReturnsSuccess() var command = CreateCommand(console, CreateKitStoreFactory()); // Act - var result = await command.ExecuteAsync(new FeaturesListCommandOptions()); + var result = await command.ExecuteAsync(new FeatureListCommandOptions()); // Assert console.Lines @@ -91,7 +91,7 @@ public async Task ExecuteAsync_KitRootNotFound_ThrowsAbortExceptionWithKitRootNo var command = CreateCommand(console, kitStoreFactory); // Act - var act = () => command.ExecuteAsync(new FeaturesListCommandOptions()); + var act = () => command.ExecuteAsync(new FeatureListCommandOptions()); // Assert var exception = (await act.Should().ThrowAsync()).Which; @@ -113,47 +113,45 @@ public async Task ExecuteAsync_KitRootNotFound_ThrowsAbortExceptionWithKitRootNo } [Fact] - public async Task ExecuteAsync_FeaturesDirectoryNotFound_ThrowsAbortExceptionWithFeaturesDirectoryNotFoundExitCode() + [Trait("Requirement", "FP01-AC2")] + public async Task ExecuteAsync_NotAGitRepository_ThrowsAbortExceptionWithNotAGitRepositoryExitCodeAndEscapedPath() { // Arrange - var featuresDirectoryNotFoundException = new FeaturesDirectoryNotFoundException("/kit/[nested]/.shadekit/features"); - var kitStore = A.Fake(); - A.CallTo(() => kitStore.GetFeatureNames()).Throws(featuresDirectoryNotFoundException); - - var kitStoreFactory = CreateKitStoreFactory(kitStore); + var notAGitRepositoryException = new NotAGitRepositoryException("/kit/[nested]/src"); + var kitStoreFactory = A.Fake(); + A.CallTo(() => kitStoreFactory.Create()).Throws(notAGitRepositoryException); var console = new TestConsole(); var command = CreateCommand(console, kitStoreFactory); // Act - var act = () => command.ExecuteAsync(new FeaturesListCommandOptions()); + var act = () => command.ExecuteAsync(new FeatureListCommandOptions()); // Assert var exception = (await act.Should().ThrowAsync()).Which; exception.ExitCode - .Should().Be(ShadeKitCliExitCodes.FeaturesDirectoryNotFound); + .Should().Be(ShadeKitCliExitCodes.NotAGitRepository); exception.IsError .Should().BeTrue(); exception.InnerException - .Should().BeSameAs(featuresDirectoryNotFoundException); + .Should().BeSameAs(notAGitRepositoryException); exception.Message - .Should().Contain("'features'") - .And.Contain("'/kit/[[nested]]/.shadekit/features'"); + .Should().Contain("'/kit/[[nested]]/src'"); console.Output .Should().BeEmpty(); } [Fact] - public async Task ExecuteAsync_LazyFeatureNamesThrowFeaturesDirectoryNotFoundOnEnumeration_ThrowsAbortExceptionWithFeaturesDirectoryNotFoundExitCode() + public async Task ExecuteAsync_LazyFeatureNamesThrowMappedExceptionOnEnumeration_ThrowsAbortExceptionWithMappedExitCode() { // Arrange var kitStore = A.Fake(); - A.CallTo(() => kitStore.GetFeatureNames()).Returns(ThrowOnEnumeration(new FeaturesDirectoryNotFoundException("/kit/.shadekit/features"))); + A.CallTo(() => kitStore.GetFeatureNames()).Returns(ThrowOnEnumeration(new KitRootPathNotFoundException("/kit/src", "/kit"))); var kitStoreFactory = CreateKitStoreFactory(kitStore); @@ -161,13 +159,13 @@ public async Task ExecuteAsync_LazyFeatureNamesThrowFeaturesDirectoryNotFoundOnE var command = CreateCommand(console, kitStoreFactory); // Act - var act = () => command.ExecuteAsync(new FeaturesListCommandOptions()); + var act = () => command.ExecuteAsync(new FeatureListCommandOptions()); // Assert var exception = (await act.Should().ThrowAsync()).Which; exception.ExitCode - .Should().Be(ShadeKitCliExitCodes.FeaturesDirectoryNotFound); + .Should().Be(ShadeKitCliExitCodes.KitRootNotFound); console.Output .Should().BeEmpty(); @@ -185,7 +183,7 @@ public async Task ExecuteAsync_FactoryThrowsDirectoryNotFound_PropagatesExceptio var command = CreateCommand(console, kitStoreFactory); // Act - var act = () => command.ExecuteAsync(new FeaturesListCommandOptions()); + var act = () => command.ExecuteAsync(new FeatureListCommandOptions()); // Assert (await act.Should().ThrowAsync()) @@ -206,15 +204,21 @@ public void ExecuteAsync_KitRootNotFound_ReturnsFaultedTaskInsteadOfThrowingSync Task? task = null; // Act - var act = () => { task = command.ExecuteAsync(new FeaturesListCommandOptions()); }; + var act = () => { task = command.ExecuteAsync(new FeatureListCommandOptions()); }; // Assert act.Should().NotThrow(); - task!.IsFaulted + task + .Should().NotBeNull(); + + task.IsFaulted .Should().BeTrue(); - task.Exception!.InnerException + task.Exception + .Should().NotBeNull(); + + task.Exception.InnerException .Should().BeOfType(); } @@ -232,7 +236,7 @@ public async Task ExecuteAsync_LazyFeatureNamesThrowBclDirectoryNotFoundOnEnumer var command = CreateCommand(console, kitStoreFactory); // Act - var act = () => command.ExecuteAsync(new FeaturesListCommandOptions()); + var act = () => command.ExecuteAsync(new FeatureListCommandOptions()); // Assert (await act.Should().ThrowAsync()) @@ -255,7 +259,7 @@ public async Task ExecuteAsync_StoreThrowsBclDirectoryNotFound_PropagatesExcepti var command = CreateCommand(new TestConsole(), kitStoreFactory); // Act - var act = () => command.ExecuteAsync(new FeaturesListCommandOptions()); + var act = () => command.ExecuteAsync(new FeatureListCommandOptions()); // Assert (await act.Should().ThrowAsync()) @@ -273,7 +277,7 @@ public async Task ExecuteAsync_FactoryThrowsUnexpectedIoException_PropagatesExce var command = CreateCommand(new TestConsole(), kitStoreFactory); // Act - var act = () => command.ExecuteAsync(new FeaturesListCommandOptions()); + var act = () => command.ExecuteAsync(new FeatureListCommandOptions()); // Assert (await act.Should().ThrowAsync()) @@ -294,7 +298,7 @@ public async Task ExecuteAsync_StoreThrowsUnexpectedIoException_PropagatesExcept var command = CreateCommand(console, kitStoreFactory); // Act - var act = () => command.ExecuteAsync(new FeaturesListCommandOptions()); + var act = () => command.ExecuteAsync(new FeatureListCommandOptions()); // Assert (await act.Should().ThrowAsync()) @@ -308,7 +312,7 @@ public async Task ExecuteAsync_StoreThrowsUnexpectedIoException_PropagatesExcept public void Ctor_NullConsole_ThrowsArgumentNullException() { // Act - var act = () => new FeaturesListCommand(null!, A.Fake(), A.Fake(), + var act = () => new FeatureListCommand(null!, A.Fake(), A.Fake(), CliErrorMapperTests.CreateMapper()); // Assert @@ -320,7 +324,7 @@ public void Ctor_NullConsole_ThrowsArgumentNullException() public void Ctor_NullKitStoreFactory_ThrowsArgumentNullException() { // Act - var act = () => new FeaturesListCommand(new TestConsole(), null!, A.Fake(), + var act = () => new FeatureListCommand(new TestConsole(), null!, A.Fake(), CliErrorMapperTests.CreateMapper()); // Assert @@ -332,7 +336,7 @@ public void Ctor_NullKitStoreFactory_ThrowsArgumentNullException() public void Ctor_NullJsonWriter_ThrowsArgumentNullException() { // Act - var act = () => new FeaturesListCommand(new TestConsole(), A.Fake(), null!, + var act = () => new FeatureListCommand(new TestConsole(), A.Fake(), null!, CliErrorMapperTests.CreateMapper()); // Assert @@ -344,7 +348,7 @@ public void Ctor_NullJsonWriter_ThrowsArgumentNullException() public void Ctor_NullErrorMapper_ThrowsArgumentNullException() { // Act - var act = () => new FeaturesListCommand(new TestConsole(), A.Fake(), + var act = () => new FeatureListCommand(new TestConsole(), A.Fake(), A.Fake(), null!); // Assert @@ -376,7 +380,7 @@ public async Task ExecuteAsync_JsonModeWithFeatures_WritesEnvelopeWithFeatureNam var command = CreateCommand(console, CreateKitStoreFactory("login", "Billing", "auth"), jsonOutput); // Act - var result = await command.ExecuteAsync(new FeaturesListCommandOptions { Json = true }); + var result = await command.ExecuteAsync(new FeatureListCommandOptions { Json = true }); // Assert using var document = JsonDocument.Parse(jsonOutput.ToString()); @@ -407,7 +411,7 @@ public async Task ExecuteAsync_JsonModeWithoutFeatures_WritesEmptyFeatureListAsR var command = CreateCommand(new TestConsole(), CreateKitStoreFactory(), jsonOutput); // Act - var result = await command.ExecuteAsync(new FeaturesListCommandOptions { Json = true }); + var result = await command.ExecuteAsync(new FeatureListCommandOptions { Json = true }); // Assert using var document = JsonDocument.Parse(jsonOutput.ToString()); @@ -437,7 +441,7 @@ public async Task ExecuteAsync_JsonModeKitRootNotFound_WritesErrorEnvelopeAndAbo var command = CreateCommand(console, kitStoreFactory, jsonOutput); // Act - var act = () => command.ExecuteAsync(new FeaturesListCommandOptions { Json = true }); + var act = () => command.ExecuteAsync(new FeatureListCommandOptions { Json = true }); // Assert var exception = (await act.Should().ThrowAsync()).Which; @@ -472,28 +476,26 @@ public async Task ExecuteAsync_JsonModeKitRootNotFound_WritesErrorEnvelopeAndAbo [Fact] [Trait("Requirement", "FP01-AC8")] - public async Task ExecuteAsync_JsonModeFeaturesDirectoryNotFound_WritesErrorEnvelopeWithFeaturesDirCode() + public async Task ExecuteAsync_JsonModeNotAGitRepository_WritesErrorEnvelopeWithNotAGitRepositoryCode() { // Arrange - var kitStore = A.Fake(); - A.CallTo(() => kitStore.GetFeatureNames()).Throws(new FeaturesDirectoryNotFoundException("/repo/.shadekit/features")); - - var kitStoreFactory = CreateKitStoreFactory(kitStore); + var kitStoreFactory = A.Fake(); + A.CallTo(() => kitStoreFactory.Create()).Throws(new NotAGitRepositoryException("/outside")); using var jsonOutput = new StringWriter(); var command = CreateCommand(new TestConsole(), kitStoreFactory, jsonOutput); // Act - var act = () => command.ExecuteAsync(new FeaturesListCommandOptions { Json = true }); + var act = () => command.ExecuteAsync(new FeatureListCommandOptions { Json = true }); // Assert (await act.Should().ThrowAsync()) - .Which.ExitCode.Should().Be(ShadeKitCliExitCodes.FeaturesDirectoryNotFound); + .Which.ExitCode.Should().Be(ShadeKitCliExitCodes.NotAGitRepository); using var document = JsonDocument.Parse(jsonOutput.ToString()); document.RootElement.GetProperty("error").GetProperty("code").GetString() - .Should().Be(ShadeKitCliErrorCodes.FeaturesDirectoryNotFound); + .Should().Be(ShadeKitCliErrorCodes.NotAGitRepository); } [Fact] @@ -508,7 +510,7 @@ public async Task ExecuteAsync_JsonModeUnexpectedException_PropagatesExceptionWi var command = CreateCommand(new TestConsole(), kitStoreFactory, jsonOutput); // Act - var act = () => command.ExecuteAsync(new FeaturesListCommandOptions { Json = true }); + var act = () => command.ExecuteAsync(new FeatureListCommandOptions { Json = true }); // Assert (await act.Should().ThrowAsync()) @@ -526,7 +528,7 @@ public async Task ExecuteAsync_PlainMode_WritesNoJson() var command = CreateCommand(new TestConsole(), CreateKitStoreFactory("auth"), jsonOutput); // Act - await command.ExecuteAsync(new FeaturesListCommandOptions()); + await command.ExecuteAsync(new FeatureListCommandOptions()); // Assert jsonOutput.ToString() @@ -534,37 +536,37 @@ public async Task ExecuteAsync_PlainMode_WritesNoJson() } [Fact] - public void FeaturesListCommandOptions_Type_DerivesFromJsonCommandOptions() + public void FeatureListCommandOptions_Type_DerivesFromJsonCommandOptions() { // Assert - typeof(FeaturesListCommandOptions) + typeof(FeatureListCommandOptions) .Should().BeDerivedFrom(); } [Fact] - public void FeaturesListCommand_CliCommandAttribute_IsRegisteredAsFeaturesListCommand() + public void FeatureListCommand_CliCommandAttribute_IsRegisteredAsFeatureListCommand() { // Act - var attribute = typeof(FeaturesListCommand).GetCustomAttribute(); + var attribute = typeof(FeatureListCommand).GetCustomAttribute(); // Assert attribute .Should().NotBeNull(); - attribute!.Commands - .Should().Equal("features", "list"); + attribute.Commands + .Should().Equal("feature", "list"); } [Fact] public void CliAssembly_CliCommandGroupAttributes_ContainFeaturesGroup() { // Act - var groupAttributes = typeof(FeaturesListCommand).Assembly + var groupAttributes = typeof(FeatureListCommand).Assembly .GetCustomAttributes(); // Assert groupAttributes - .Where(x => x.Commands is ["features"]) + .Where(x => x.Commands is ["feature"]) .Should().ContainSingle(); } @@ -598,7 +600,7 @@ public async Task ExecuteAsync_JsonModeEnumerationFailsMidway_WritesOnlyErrorEnv // Arrange var kitStore = A.Fake(); A.CallTo(() => kitStore.GetFeatureNames()) - .Returns(ThrowOnEnumeration(new FeaturesDirectoryNotFoundException("/repo/.shadekit/features"))); + .Returns(ThrowOnEnumeration(new KitRootPathNotFoundException("/repo/src", "/repo"))); var kitStoreFactory = CreateKitStoreFactory(kitStore); @@ -606,7 +608,7 @@ public async Task ExecuteAsync_JsonModeEnumerationFailsMidway_WritesOnlyErrorEnv var command = CreateCommand(new TestConsole(), kitStoreFactory, jsonOutput); // Act - var act = () => command.ExecuteAsync(new FeaturesListCommandOptions { Json = true }); + var act = () => command.ExecuteAsync(new FeatureListCommandOptions { Json = true }); // Assert await act.Should().ThrowAsync(); @@ -628,7 +630,7 @@ public async Task ExecuteAsync_JsonModeFeatureNameWithMarkupCharacters_WritesNam var command = CreateCommand(new TestConsole(), CreateKitStoreFactory("[red]feature[/]"), jsonOutput); // Act - await command.ExecuteAsync(new FeaturesListCommandOptions { Json = true }); + await command.ExecuteAsync(new FeatureListCommandOptions { Json = true }); // Assert using var document = JsonDocument.Parse(jsonOutput.ToString()); @@ -641,23 +643,21 @@ public async Task ExecuteAsync_JsonModeFeatureNameWithMarkupCharacters_WritesNam public async Task ExecuteAsync_JsonModePathWithMarkupCharacters_KeepsRawPathInEnvelopeAndAbortMessage() { // Arrange - var exception = new FeaturesDirectoryNotFoundException("/kit/[nested]/.shadekit/features"); - var kitStore = A.Fake(); - A.CallTo(() => kitStore.GetFeatureNames()).Throws(exception); - - var kitStoreFactory = CreateKitStoreFactory(kitStore); + var exception = new NotAGitRepositoryException("/kit/[nested]/src"); + var kitStoreFactory = A.Fake(); + A.CallTo(() => kitStoreFactory.Create()).Throws(exception); using var jsonOutput = new StringWriter(); var command = CreateCommand(new TestConsole(), kitStoreFactory, jsonOutput); // Act - var act = () => command.ExecuteAsync(new FeaturesListCommandOptions { Json = true }); + var act = () => command.ExecuteAsync(new FeatureListCommandOptions { Json = true }); // Assert var abortException = (await act.Should().ThrowAsync()).Which; abortException.Message - .Should().Contain("'/kit/[nested]/.shadekit/features'"); + .Should().Contain("'/kit/[nested]/src'"); abortException.PrintMessage .Should().BeFalse(); @@ -672,7 +672,7 @@ public async Task ExecuteAsync_JsonModePathWithMarkupCharacters_KeepsRawPathInEn var root = document.RootElement; root.GetProperty("error").GetProperty("message").GetString() - .Should().Contain("'/kit/[nested]/.shadekit/features'"); + .Should().Contain("'/kit/[nested]/src'"); root.GetProperty("warnings").GetArrayLength() .Should().Be(0); @@ -681,10 +681,10 @@ public async Task ExecuteAsync_JsonModePathWithMarkupCharacters_KeepsRawPathInEn .Should().Equal("code", "message"); } - internal static FeaturesListCommand CreateCommand(IAnsiConsole console, IKitStoreFactory kitStoreFactory, + internal static FeatureListCommand CreateCommand(IAnsiConsole console, IKitStoreFactory kitStoreFactory, TextWriter? jsonOutput = null) { - return new FeaturesListCommand(console, kitStoreFactory, new CliJsonWriter(jsonOutput ?? TextWriter.Null), + return new FeatureListCommand(console, kitStoreFactory, new CliJsonWriter(jsonOutput ?? TextWriter.Null), CliErrorMapperTests.CreateMapper()); } } diff --git a/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Features/List/FeaturesListCommandCurrentDirectoryTests.cs b/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Features/List/FeaturesListCommandCurrentDirectoryTests.cs deleted file mode 100644 index ded7ea3..0000000 --- a/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Features/List/FeaturesListCommandCurrentDirectoryTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -using AwesomeAssertions; -using CreativeCoders.Cli.Hosting.Exceptions; -using CreativeCoders.ShadeKit.Cli.Commands.Features.List; -using CreativeCoders.ShadeKit.Core.Store; -using FakeItEasy; -using Spectre.Console; -using Spectre.Console.Testing; - -namespace CreativeCoders.ShadeKit.Cli.Tests.Commands.Features.List; - -[Collection(CurrentDirectoryCollectionDefinition.Name)] -public class FeaturesListCommandCurrentDirectoryTests -{ - [Fact] - public async Task ExecuteAsync_NotAGitRepositoryInDirectoryWithMarkupCharacters_EscapesAbortMessage() - { - // Arrange - var kitStoreFactory = A.Fake(); - - var command = FeaturesListCommandTests.CreateCommand(new TestConsole(), kitStoreFactory); - - var originalDirectory = Directory.GetCurrentDirectory(); - var markupDirectory = Directory.CreateTempSubdirectory("shade-kit-[tests]-"); - - try - { - Directory.SetCurrentDirectory(markupDirectory.FullName); - A.CallTo(() => kitStoreFactory.Create()) - .Throws(new NotAGitRepositoryException(Directory.GetCurrentDirectory())); - - // Act - var act = () => command.ExecuteAsync(new FeaturesListCommandOptions()); - - // Assert - var exception = (await act.Should().ThrowAsync()).Which; - - exception.Message - .Should().Contain(Markup.Escape(Directory.GetCurrentDirectory())); - } - finally - { - Directory.SetCurrentDirectory(originalDirectory); - markupDirectory.Delete(); - } - } -} diff --git a/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Init/InitCommandCurrentDirectoryTests.cs b/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Init/InitCommandCurrentDirectoryTests.cs new file mode 100644 index 0000000..7f2e75b --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Init/InitCommandCurrentDirectoryTests.cs @@ -0,0 +1,40 @@ +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Cli.Commands.Init; +using CreativeCoders.ShadeKit.Core.Init; +using CreativeCoders.ShadeKit.Tests.Shared; +using FakeItEasy; +using Spectre.Console.Testing; + +namespace CreativeCoders.ShadeKit.Cli.Tests.Commands.Init; + +[Collection(CurrentDirectoryCollectionDefinition.Name)] +public sealed class InitCommandCurrentDirectoryTests : IDisposable +{ + private readonly CurrentDirectoryScope _currentDirectory = new(); + + private readonly TempKitDirectory _tempDirectory = new("shade-kit-[init]-"); + + [Fact] + public async Task ExecuteAsync_CurrentDirectory_PassesCurrentDirectoryToInitializer() + { + // Arrange + var initializer = InitCommandTests.CreateInitializer(new RepositoryInitResult("/repo", [])); + var command = InitCommandTests.CreateCommand(new TestConsole(), initializer); + + Directory.SetCurrentDirectory(_tempDirectory.RootPath); + var currentDirectory = Directory.GetCurrentDirectory(); + + // Act + await command.ExecuteAsync(new InitCommandOptions()); + + // Assert + A.CallTo(() => initializer.InitializeAsync(currentDirectory, A._)) + .MustHaveHappenedOnceExactly(); + } + + public void Dispose() + { + _currentDirectory.Dispose(); + _tempDirectory.Dispose(); + } +} diff --git a/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Init/InitCommandEndToEndTests.cs b/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Init/InitCommandEndToEndTests.cs new file mode 100644 index 0000000..65f3a62 --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Init/InitCommandEndToEndTests.cs @@ -0,0 +1,449 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Templates; +using CreativeCoders.ShadeKit.Tests.Shared; + +namespace CreativeCoders.ShadeKit.Cli.Tests.Commands.Init; + +/// +/// Runs shade-kit init through against a real temporary repository. +/// +[Collection(CurrentDirectoryCollectionDefinition.Name)] +public sealed partial class InitCommandEndToEndTests : IDisposable +{ + private readonly CurrentDirectoryScope _currentDirectory = new(); + + private readonly TempKitDirectory _tempDirectory = new("shade-kit-init-tests-"); + + [Fact] + [Trait("Requirement", "FP02-AC1")] + [Trait("Requirement", "FP02-AC8")] + public async Task RunAsync_InitJsonInGitRepository_CreatesKitFilesAndListsThemInWriteOrder() + { + // Arrange + var repositoryRoot = EnterGitRepository(); + + using var output = new StringWriter(); + using var error = new StringWriter(); + + // Act + var exitCode = await Program.RunAsync(["init", "--json"], output, error); + + // Assert + exitCode + .Should().Be(0); + + using var document = JsonDocument.Parse(output.ToString()); + var root = document.RootElement; + + root.GetProperty("ok").GetBoolean() + .Should().BeTrue(); + + root.GetProperty("data").GetProperty("repositoryRoot").GetString() + .Should().Be(repositoryRoot); + + var files = root.GetProperty("data").GetProperty("files").EnumerateArray() + .Select(x => (Path: x.GetProperty("path").GetString(), Action: x.GetProperty("action").GetString())) + .ToArray(); + + string[] expectedPaths = + [ + ".shadekit/config.json", + ".shadekit/gates.json", + .. ShippedTemplates.All.Select(x => x.RepositoryPath), + ".shadekit/templates.lock.json", + ".shadekit/features/", + ".gitignore", + "CLAUDE.md", + "AGENTS.md" + ]; + + files.Select(x => x.Path) + .Should().Equal(expectedPaths); + + files.Select(x => x.Action) + .Should().AllBe("created"); + + files.Select(x => Path.Combine(_tempDirectory.RootPath, x.Path!)) + .Should().AllSatisfy(x => Path.Exists(x).Should().BeTrue(x)); + + Directory.Exists(Path.Combine(_tempDirectory.RootPath, ".shadekit", "features")) + .Should().BeTrue(); + + error.ToString() + .Should().BeNullOrWhiteSpace("the host writes only its empty footer line to standard error"); + } + + [Fact] + [Trait("Requirement", "FP02-AC6")] + public async Task RunAsync_InitJsonInInitialisedRepository_Returns13WithUpgradeHintAndChangesNoFile() + { + // Arrange + EnterGitRepository(); + + (await Program.RunAsync(["init", "--json"], TextWriter.Null, TextWriter.Null)) + .Should().Be(0); + + var snapshot = _tempDirectory.TakeSnapshot(); + + using var output = new StringWriter(); + using var error = new StringWriter(); + + // Act + var exitCode = await Program.RunAsync(["init", "--json"], output, error); + + // Assert + exitCode + .Should().Be(ShadeKitCliExitCodes.KitAlreadyInitialized); + + using var document = JsonDocument.Parse(output.ToString()); + var root = document.RootElement; + + root.GetProperty("ok").GetBoolean() + .Should().BeFalse(); + + root.GetProperty("error").GetProperty("code").GetString() + .Should().Be(ShadeKitCliErrorCodes.KitAlreadyInitialized); + + root.GetProperty("error").GetProperty("message").GetString() + .Should().Contain("shade-kit upgrade"); + + _tempDirectory.TakeSnapshot() + .Should().Equal(snapshot); + } + + [Fact] + [Trait("Requirement", "FP02-AC6")] + public async Task RunAsync_InitPlainInInitialisedRepository_PrintsUpgradeHintWithoutJsonAndChangesNoFile() + { + // Arrange + EnterGitRepository(); + + (await Program.RunAsync(["init"], TextWriter.Null, TextWriter.Null)) + .Should().Be(0); + + var snapshot = _tempDirectory.TakeSnapshot(); + + using var output = new StringWriter(); + using var error = new StringWriter(); + + // Act + var exitCode = await Program.RunAsync(["init"], output, error); + + // Assert + exitCode + .Should().Be(ShadeKitCliExitCodes.KitAlreadyInitialized); + + // The console wraps long lines, so line breaks are folded back into single spaces. + var text = WhitespaceRegex().Replace(output.ToString(), " "); + + text.Should().Contain("already initialised") + .And.Contain("shade-kit upgrade") + .And.NotContain("\"ok\""); + + _tempDirectory.TakeSnapshot() + .Should().Equal(snapshot); + } + + [Fact] + [Trait("Requirement", "FP02-AC4")] + [Trait("Requirement", "FP02-AC5")] + public async Task RunAsync_InitJsonWithExistingRepositoryFiles_ReportsAppendedAndUpdatedFiles() + { + // Arrange + EnterGitRepository(); + var cancellationToken = TestContext.Current.CancellationToken; + + await File.WriteAllTextAsync(Path.Combine(_tempDirectory.RootPath, ".gitignore"), "bin/\n", + cancellationToken); + await File.WriteAllTextAsync(Path.Combine(_tempDirectory.RootPath, "CLAUDE.md"), + "# Project\n\nold\n\n", cancellationToken); + await File.WriteAllTextAsync(Path.Combine(_tempDirectory.RootPath, "AGENTS.md"), "# Agents\n", + cancellationToken); + + using var output = new StringWriter(); + using var error = new StringWriter(); + + // Act + var exitCode = await Program.RunAsync(["init", "--json"], output, error); + + // Assert + exitCode + .Should().Be(0); + + using var document = JsonDocument.Parse(output.ToString()); + + document.RootElement.GetProperty("data").GetProperty("files").EnumerateArray() + .Select(x => (x.GetProperty("path").GetString(), x.GetProperty("action").GetString())) + .TakeLast(3) + .Should().Equal((".gitignore", "appended"), ("CLAUDE.md", "updated"), ("AGENTS.md", "appended")); + } + + [Fact] + public async Task RunAsync_InitJsonInSubdirectory_InitialisesRepositoryRoot() + { + // Arrange + var repositoryRoot = EnterGitRepository(); + Directory.SetCurrentDirectory(Directory.CreateDirectory(Path.Combine(repositoryRoot, "src", "app")).FullName); + + using var output = new StringWriter(); + using var error = new StringWriter(); + + // Act + var exitCode = await Program.RunAsync(["init", "--json"], output, error); + + // Assert + exitCode + .Should().Be(0); + + using var document = JsonDocument.Parse(output.ToString()); + + document.RootElement.GetProperty("data").GetProperty("repositoryRoot").GetString() + .Should().Be(repositoryRoot); + + File.Exists(Path.Combine(repositoryRoot, ".shadekit", "config.json")) + .Should().BeTrue(); + + Path.Exists(Path.Combine(repositoryRoot, "src", "app", ".shadekit")) + .Should().BeFalse(); + } + + [Fact] + [Trait("Requirement", "FP02-AC7")] + public async Task RunAsync_InitJsonOutsideGitRepository_Returns12AndCreatesNothing() + { + // Arrange + _tempDirectory.SkipWhenGitRootExistsAbove(); + + Directory.SetCurrentDirectory(_tempDirectory.RootPath); + + using var output = new StringWriter(); + using var error = new StringWriter(); + + // Act + var exitCode = await Program.RunAsync(["init", "--json"], output, error); + + // Assert + exitCode + .Should().Be(ShadeKitCliExitCodes.NotAGitRepository); + + using var document = JsonDocument.Parse(output.ToString()); + + document.RootElement.GetProperty("error").GetProperty("code").GetString() + .Should().Be(ShadeKitCliErrorCodes.NotAGitRepository); + + Directory.EnumerateFileSystemEntries(_tempDirectory.RootPath) + .Should().BeEmpty(); + } + + [Fact] + public async Task RunAsync_InitJsonWithInvalidSectionMarkers_Returns23AndCreatesNoKitRoot() + { + // Arrange + EnterGitRepository(); + var claudePath = Path.Combine(_tempDirectory.RootPath, "CLAUDE.md"); + await File.WriteAllTextAsync(claudePath, "# Project\n\n", + TestContext.Current.CancellationToken); + + using var output = new StringWriter(); + using var error = new StringWriter(); + + // Act + var exitCode = await Program.RunAsync(["init", "--json"], output, error); + + // Assert + exitCode + .Should().Be(ShadeKitCliExitCodes.InvalidManagedSection); + + using var document = JsonDocument.Parse(output.ToString()); + + document.RootElement.GetProperty("error").GetProperty("code").GetString() + .Should().Be(ShadeKitCliErrorCodes.InvalidManagedSection); + + document.RootElement.GetProperty("error").GetProperty("message").GetString() + .Should().Contain("CLAUDE.md"); + + Path.Exists(Path.Combine(_tempDirectory.RootPath, ".shadekit")) + .Should().BeFalse(); + } + + [Fact] + public async Task RunAsync_InitJsonWithUtf16GitIgnore_Returns24AndChangesNoFile() + { + // Arrange + EnterGitRepository(); + var gitIgnorePath = Path.Combine(_tempDirectory.RootPath, ".gitignore"); + await File.WriteAllBytesAsync(gitIgnorePath, [0xFF, 0xFE, .. Encoding.Unicode.GetBytes("bin/\r\n")], + TestContext.Current.CancellationToken); + var snapshot = _tempDirectory.TakeSnapshot(); + + using var output = new StringWriter(); + using var error = new StringWriter(); + + // Act + var exitCode = await Program.RunAsync(["init", "--json"], output, error); + + // Assert + exitCode + .Should().Be(ShadeKitCliExitCodes.UnsupportedFileEncoding); + + using var document = JsonDocument.Parse(output.ToString()); + + document.RootElement.GetProperty("ok").GetBoolean() + .Should().BeFalse(); + + document.RootElement.GetProperty("error").GetProperty("code").GetString() + .Should().Be(ShadeKitCliErrorCodes.UnsupportedFileEncoding); + + document.RootElement.GetProperty("error").GetProperty("message").GetString() + .Should().Contain(".gitignore") + .And.Contain("UTF-16 LE"); + + _tempDirectory.TakeSnapshot() + .Should().BeEquivalentTo(snapshot); + } + + [Theory] + [InlineData("init")] + [InlineData("feature list")] + public async Task RunAsync_KitRootIsFile_Returns15AndChangesNoFile(string command) + { + // Arrange + EnterGitRepository(); + _tempDirectory.WriteFile("not a directory", ".shadekit"); + var snapshot = _tempDirectory.TakeSnapshot(); + + using var output = new StringWriter(); + using var error = new StringWriter(); + + // Act + var exitCode = await Program.RunAsync([.. command.Split(' '), "--json"], output, error); + + // Assert + exitCode + .Should().Be(ShadeKitCliExitCodes.KitRootNotADirectory); + + using var document = JsonDocument.Parse(output.ToString()); + + document.RootElement.GetProperty("error").GetProperty("code").GetString() + .Should().Be(ShadeKitCliErrorCodes.KitRootNotADirectory); + + document.RootElement.GetProperty("error").GetProperty("message").GetString() + .Should().Contain("shade-kit init") + .And.NotContain("upgrade"); + + _tempDirectory.TakeSnapshot() + .Should().BeEquivalentTo(snapshot); + } + + [Fact] + public async Task RunAsync_InitJsonWithClaudeMdDirectory_Returns14AndChangesNoFile() + { + // Arrange + EnterGitRepository(); + _tempDirectory.CreateDirectory("CLAUDE.md"); + var snapshot = _tempDirectory.TakeSnapshot(); + + using var output = new StringWriter(); + using var error = new StringWriter(); + + // Act + var exitCode = await Program.RunAsync(["init", "--json"], output, error); + + // Assert + exitCode + .Should().Be(ShadeKitCliExitCodes.PathNotAFile); + + using var document = JsonDocument.Parse(output.ToString()); + + document.RootElement.GetProperty("ok").GetBoolean() + .Should().BeFalse(); + + document.RootElement.GetProperty("error").GetProperty("code").GetString() + .Should().Be(ShadeKitCliErrorCodes.PathNotAFile); + + document.RootElement.GetProperty("error").GetProperty("message").GetString() + .Should().Contain("CLAUDE.md"); + + _tempDirectory.TakeSnapshot() + .Should().BeEquivalentTo(snapshot); + } + + [Fact] + [Trait("Requirement", "FP02-AC1")] + public async Task RunAsync_FeatureListJsonAfterInit_ReturnsEmptyFeatureList() + { + // Arrange + EnterGitRepository(); + + (await Program.RunAsync(["init", "--json"], TextWriter.Null, TextWriter.Null)) + .Should().Be(0); + + using var output = new StringWriter(); + using var error = new StringWriter(); + + // Act + var exitCode = await Program.RunAsync(["feature", "list", "--json"], output, error); + + // Assert + exitCode + .Should().Be(0); + + using var document = JsonDocument.Parse(output.ToString()); + + document.RootElement.GetProperty("ok").GetBoolean() + .Should().BeTrue(); + + document.RootElement.GetProperty("data").GetProperty("features").GetArrayLength() + .Should().Be(0); + } + + [Fact] + public async Task RunAsync_InitPlain_PrintsSummaryAndReturns0() + { + // Arrange + EnterGitRepository(); + + using var output = new StringWriter(); + using var error = new StringWriter(); + + // Act + var exitCode = await Program.RunAsync(["init"], output, error); + + // Assert + exitCode + .Should().Be(0); + + var lines = output.ToString().Split(Environment.NewLine); + + lines[0] + .Should().StartWith("Initialised SHADE kit in "); + + lines + .Should().Contain(["created .shadekit/config.json", "created .gitignore", "created AGENTS.md"]) + .And.Contain(x => x.StartsWith("Commit '.shadekit/'", StringComparison.Ordinal)); + } + + public void Dispose() + { + _currentDirectory.Dispose(); + _tempDirectory.Dispose(); + } + + /// + /// Marks the temporary directory as a git repository root, makes it the current directory, and returns the + /// current directory as the process sees it (on macOS, the temporary directory resolves to /private/var). + /// + private string EnterGitRepository() + { + _tempDirectory.CreateGitRoot(); + Directory.SetCurrentDirectory(_tempDirectory.RootPath); + + return Directory.GetCurrentDirectory(); + } + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRegex(); +} diff --git a/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Init/InitCommandTests.cs b/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Init/InitCommandTests.cs new file mode 100644 index 0000000..a749843 --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Init/InitCommandTests.cs @@ -0,0 +1,581 @@ +using System.Reflection; +using System.Text.Json; +using AwesomeAssertions; +using CreativeCoders.Cli.Core; +using CreativeCoders.Cli.Hosting.Exceptions; +using CreativeCoders.ShadeKit.Cli.Commands; +using CreativeCoders.ShadeKit.Cli.Commands.Init; +using CreativeCoders.ShadeKit.Cli.Output; +using CreativeCoders.ShadeKit.Cli.Tests.Output; +using CreativeCoders.ShadeKit.Core.Init; +using CreativeCoders.ShadeKit.Core.Store; +using FakeItEasy; +using Spectre.Console; +using Spectre.Console.Testing; + +namespace CreativeCoders.ShadeKit.Cli.Tests.Commands.Init; + +public class InitCommandTests +{ + private static readonly RepositoryInitResult Result = new("/repo", + [ + new InitializedFile(".shadekit/config.json", InitializedFileAction.Created), + new InitializedFile(".shadekit/features/", InitializedFileAction.Created), + new InitializedFile(".gitignore", InitializedFileAction.Appended), + new InitializedFile("CLAUDE.md", InitializedFileAction.Updated) + ]); + + [Fact] + [Trait("Requirement", "FP02-AC1")] + public async Task ExecuteAsync_PlainMode_WritesSummaryWithOneLinePerFileAndCommitHint() + { + // Arrange + var console = new TestConsole(); + var command = CreateCommand(console, CreateInitializer(Result)); + + // Act + var result = await command.ExecuteAsync(new InitCommandOptions()); + + // Assert + console.Lines + .Should().Equal( + "Initialised SHADE kit in /repo.", + "created .shadekit/config.json", + "created .shadekit/features/", + "appended .gitignore", + "updated CLAUDE.md", + "Commit '.shadekit/' and the changed files together with your code."); + + result.ExitCode + .Should().Be(0); + } + + [Fact] + public async Task ExecuteAsync_PlainMode_WritesNoJson() + { + // Arrange + using var jsonOutput = new StringWriter(); + var command = CreateCommand(new TestConsole(), CreateInitializer(Result), jsonOutput); + + // Act + await command.ExecuteAsync(new InitCommandOptions()); + + // Assert + jsonOutput.ToString() + .Should().BeEmpty(); + } + + [Fact] + public async Task ExecuteAsync_PlainModePathsWithMarkupCharacters_WritesPathsVerbatim() + { + // Arrange + var console = new TestConsole(); + var initializer = CreateInitializer(new RepositoryInitResult("/repo/[red]x[/]", + [new InitializedFile("[docs]/CLAUDE.md", InitializedFileAction.Created)])); + + var command = CreateCommand(console, initializer); + + // Act + await command.ExecuteAsync(new InitCommandOptions()); + + // Assert + console.Lines.Take(2) + .Should().Equal("Initialised SHADE kit in /repo/[red]x[/].", "created [docs]/CLAUDE.md"); + } + + [Fact] + [Trait("Requirement", "FP02-AC1")] + public async Task ExecuteAsync_JsonMode_WritesEnvelopeWithRepositoryRootAndFileActions() + { + // Arrange + var console = new TestConsole(); + using var jsonOutput = new StringWriter(); + var command = CreateCommand(console, CreateInitializer(Result), jsonOutput); + + // Act + var result = await command.ExecuteAsync(new InitCommandOptions { Json = true }); + + // Assert + jsonOutput.ToString() + .Should().Be( + """ + { + "ok": true, + "data": { + "repositoryRoot": "/repo", + "files": [ + { + "path": ".shadekit/config.json", + "action": "created" + }, + { + "path": ".shadekit/features/", + "action": "created" + }, + { + "path": ".gitignore", + "action": "appended" + }, + { + "path": "CLAUDE.md", + "action": "updated" + } + ] + }, + "warnings": [], + "error": null + } + + """.ReplaceLineEndings("\n")); + + result.ExitCode + .Should().Be(0); + + console.Output + .Should().BeEmpty(); + } + + [Fact] + public async Task ExecuteAsync_JsonModePathsWithMarkupCharacters_WritesPathsVerbatim() + { + // Arrange + using var jsonOutput = new StringWriter(); + var initializer = CreateInitializer(new RepositoryInitResult("/repo/[red]x[/]", + [new InitializedFile("[docs]/CLAUDE.md", InitializedFileAction.Created)])); + + var command = CreateCommand(new TestConsole(), initializer, jsonOutput); + + // Act + await command.ExecuteAsync(new InitCommandOptions { Json = true }); + + // Assert + using var document = JsonDocument.Parse(jsonOutput.ToString()); + var data = document.RootElement.GetProperty("data"); + + data.GetProperty("repositoryRoot").GetString() + .Should().Be("/repo/[red]x[/]"); + + data.GetProperty("files")[0].GetProperty("path").GetString() + .Should().Be("[docs]/CLAUDE.md"); + } + + [Fact] + public async Task ExecuteAsync_PlainModeWithoutFiles_WritesHeaderAndCommitHintOnly() + { + // Arrange + var console = new TestConsole(); + var command = CreateCommand(console, CreateInitializer(new RepositoryInitResult("/repo", []))); + + // Act + await command.ExecuteAsync(new InitCommandOptions()); + + // Assert + console.Lines + .Should().Equal( + "Initialised SHADE kit in /repo.", + "Commit '.shadekit/' and the changed files together with your code."); + } + + [Fact] + public async Task ExecuteAsync_JsonModeWithoutFiles_WritesEmptyFileList() + { + // Arrange + using var jsonOutput = new StringWriter(); + var command = CreateCommand(new TestConsole(), CreateInitializer(new RepositoryInitResult("/repo", [])), + jsonOutput); + + // Act + await command.ExecuteAsync(new InitCommandOptions { Json = true }); + + // Assert + using var document = JsonDocument.Parse(jsonOutput.ToString()); + + document.RootElement.GetProperty("data").GetProperty("files").GetArrayLength() + .Should().Be(0); + } + + [Fact] + public async Task ExecuteAsync_InitializerThrowsMappedExceptionSynchronously_ThrowsAbortExceptionWithMappedExitCode() + { + // Arrange + var initializer = A.Fake(); + A.CallTo(() => initializer.InitializeAsync(A._, A._)) + .Throws(new KitAlreadyInitializedException("/repo/.shadekit")); + + var command = CreateCommand(new TestConsole(), initializer); + + // Act + var act = () => command.ExecuteAsync(new InitCommandOptions()); + + // Assert + (await act.Should().ThrowAsync()) + .Which.ExitCode.Should().Be(ShadeKitCliExitCodes.KitAlreadyInitialized); + } + + [Fact] + [Trait("Requirement", "FP02-AC7")] + public async Task ExecuteAsync_NotAGitRepository_ThrowsAbortExceptionWithExitCode12AndEscapedPath() + { + // Arrange + var notAGitRepositoryException = new NotAGitRepositoryException("/kit/[nested]/src"); + var console = new TestConsole(); + var command = CreateCommand(console, CreateThrowingInitializer(notAGitRepositoryException)); + + // Act + var act = () => command.ExecuteAsync(new InitCommandOptions()); + + // Assert + var exception = (await act.Should().ThrowAsync()).Which; + + exception.ExitCode + .Should().Be(ShadeKitCliExitCodes.NotAGitRepository); + + exception.PrintMessage + .Should().BeTrue(); + + exception.InnerException + .Should().BeSameAs(notAGitRepositoryException); + + exception.Message + .Should().Contain("'/kit/[[nested]]/src'"); + + console.Output + .Should().BeEmpty(); + } + + [Fact] + [Trait("Requirement", "FP02-AC7")] + public async Task ExecuteAsync_JsonModeNotAGitRepository_WritesErrorEnvelopeAndAbortsWithoutPrintedMessage() + { + // Arrange + using var jsonOutput = new StringWriter(); + var command = CreateCommand(new TestConsole(), + CreateThrowingInitializer(new NotAGitRepositoryException("/kit/[nested]/src")), jsonOutput); + + // Act + var act = () => command.ExecuteAsync(new InitCommandOptions { Json = true }); + + // Assert + var exception = (await act.Should().ThrowAsync()).Which; + + exception.ExitCode + .Should().Be(ShadeKitCliExitCodes.NotAGitRepository); + + exception.PrintMessage + .Should().BeFalse(); + + AssertErrorEnvelope(jsonOutput, ShadeKitCliErrorCodes.NotAGitRepository) + .Should().Contain("'/kit/[nested]/src'"); + } + + [Fact] + [Trait("Requirement", "FP02-AC6")] + public async Task ExecuteAsync_KitAlreadyInitialized_ThrowsAbortExceptionWithExitCode13AndUpgradeHint() + { + // Arrange + var alreadyInitializedException = new KitAlreadyInitializedException("/repo/[x]/.shadekit"); + var console = new TestConsole(); + var command = CreateCommand(console, CreateThrowingInitializer(alreadyInitializedException)); + + // Act + var act = () => command.ExecuteAsync(new InitCommandOptions()); + + // Assert + var exception = (await act.Should().ThrowAsync()).Which; + + exception.ExitCode + .Should().Be(ShadeKitCliExitCodes.KitAlreadyInitialized); + + exception.PrintMessage + .Should().BeTrue(); + + exception.InnerException + .Should().BeSameAs(alreadyInitializedException); + + exception.Message + .Should().Contain("'/repo/[[x]]/.shadekit'") + .And.Contain("shade-kit upgrade"); + + console.Output + .Should().BeEmpty(); + } + + [Fact] + [Trait("Requirement", "FP02-AC6")] + public async Task ExecuteAsync_JsonModeKitAlreadyInitialized_WritesErrorEnvelopeWithUpgradeHintAndAbortsSilently() + { + // Arrange + using var jsonOutput = new StringWriter(); + var command = CreateCommand(new TestConsole(), + CreateThrowingInitializer(new KitAlreadyInitializedException("/repo/.shadekit")), jsonOutput); + + // Act + var act = () => command.ExecuteAsync(new InitCommandOptions { Json = true }); + + // Assert + var exception = (await act.Should().ThrowAsync()).Which; + + exception.ExitCode + .Should().Be(ShadeKitCliExitCodes.KitAlreadyInitialized); + + exception.PrintMessage + .Should().BeFalse(); + + AssertErrorEnvelope(jsonOutput, ShadeKitCliErrorCodes.KitAlreadyInitialized) + .Should().Contain("shade-kit upgrade"); + } + + [Fact] + public async Task ExecuteAsync_InvalidManagedSection_ThrowsAbortExceptionWithExitCode23AndEscapedMessage() + { + // Arrange + var invalidSectionException = new InvalidManagedSectionException("/repo/[x]/CLAUDE.md", + "The begin marker in line 3 has no end marker."); + + var console = new TestConsole(); + var command = CreateCommand(console, CreateThrowingInitializer(invalidSectionException)); + + // Act + var act = () => command.ExecuteAsync(new InitCommandOptions()); + + // Assert + var exception = (await act.Should().ThrowAsync()).Which; + + exception.ExitCode + .Should().Be(ShadeKitCliExitCodes.InvalidManagedSection); + + exception.PrintMessage + .Should().BeTrue(); + + exception.InnerException + .Should().BeSameAs(invalidSectionException); + + exception.Message + .Should().Contain("'/repo/[[x]]/CLAUDE.md'") + .And.Contain("The begin marker in line 3 has no end marker."); + + console.Output + .Should().BeEmpty(); + } + + [Fact] + public async Task ExecuteAsync_JsonModeInvalidManagedSection_WritesErrorEnvelopeAndAbortsSilently() + { + // Arrange + using var jsonOutput = new StringWriter(); + var command = CreateCommand(new TestConsole(), + CreateThrowingInitializer(new InvalidManagedSectionException("/repo/AGENTS.md", "Broken.")), jsonOutput); + + // Act + var act = () => command.ExecuteAsync(new InitCommandOptions { Json = true }); + + // Assert + var exception = (await act.Should().ThrowAsync()).Which; + + exception.ExitCode + .Should().Be(ShadeKitCliExitCodes.InvalidManagedSection); + + exception.PrintMessage + .Should().BeFalse(); + + AssertErrorEnvelope(jsonOutput, ShadeKitCliErrorCodes.InvalidManagedSection) + .Should().Contain("'/repo/AGENTS.md'"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExecuteAsync_UnexpectedException_PropagatesExceptionUnwrappedWithoutOutput(bool json) + { + // Arrange + var ioException = new IOException("disk error"); + var console = new TestConsole(); + using var jsonOutput = new StringWriter(); + var command = CreateCommand(console, CreateThrowingInitializer(ioException), jsonOutput); + + // Act + var act = () => command.ExecuteAsync(new InitCommandOptions { Json = json }); + + // Assert + (await act.Should().ThrowAsync()) + .Which.Should().BeSameAs(ioException); + + jsonOutput.ToString() + .Should().BeEmpty(); + + console.Output + .Should().BeEmpty(); + } + + [Fact] + public void ExecuteAsync_KitAlreadyInitialized_ReturnsFaultedTaskInsteadOfThrowingSynchronously() + { + // Arrange + var command = CreateCommand(new TestConsole(), + CreateThrowingInitializer(new KitAlreadyInitializedException("/repo/.shadekit"))); + + Task? task = null; + + // Act + var act = () => { task = command.ExecuteAsync(new InitCommandOptions()); }; + + // Assert + act.Should().NotThrow(); + + task + .Should().NotBeNull(); + + task.IsFaulted + .Should().BeTrue(); + + task.Exception + .Should().NotBeNull(); + + task.Exception.InnerException + .Should().BeOfType(); + } + + [Fact] + public async Task ExecuteAsync_NullOptions_ThrowsArgumentNullException() + { + // Arrange + var command = CreateCommand(new TestConsole(), CreateInitializer(Result)); + + // Act + var act = () => command.ExecuteAsync(null!); + + // Assert + (await act.Should().ThrowAsync()) + .WithParameterName("options"); + } + + [Fact] + public void Ctor_NullConsole_ThrowsArgumentNullException() + { + // Act + var act = () => new InitCommand(null!, A.Fake(), A.Fake(), + CliErrorMapperTests.CreateMapper()); + + // Assert + act.Should().Throw() + .WithParameterName("ansiConsole"); + } + + [Fact] + public void Ctor_NullRepositoryInitializer_ThrowsArgumentNullException() + { + // Act + var act = () => new InitCommand(new TestConsole(), null!, A.Fake(), + CliErrorMapperTests.CreateMapper()); + + // Assert + act.Should().Throw() + .WithParameterName("repositoryInitializer"); + } + + [Fact] + public void Ctor_NullJsonWriter_ThrowsArgumentNullException() + { + // Act + var act = () => new InitCommand(new TestConsole(), A.Fake(), null!, + CliErrorMapperTests.CreateMapper()); + + // Assert + act.Should().Throw() + .WithParameterName("jsonWriter"); + } + + [Fact] + public void Ctor_NullErrorMapper_ThrowsArgumentNullException() + { + // Act + var act = () => new InitCommand(new TestConsole(), A.Fake(), + A.Fake(), null!); + + // Assert + act.Should().Throw() + .WithParameterName("errorMapper"); + } + + [Fact] + public void InitCommandOptions_Type_DerivesFromJsonCommandOptions() + { + // Assert + typeof(InitCommandOptions) + .Should().BeDerivedFrom(); + } + + [Fact] + public void InitCommand_CliCommandAttribute_IsRegisteredAsTopLevelInitCommand() + { + // Act + var attribute = typeof(InitCommand).GetCustomAttribute(); + + // Assert + attribute + .Should().NotBeNull(); + + attribute.Commands + .Should().Equal("init"); + } + + [Fact] + public void FromResult_NullResult_ThrowsArgumentNullException() + { + // Act + var act = () => InitCommandData.FromResult(null!); + + // Assert + act.Should().Throw() + .WithParameterName("result"); + } + + internal static InitCommand CreateCommand(IAnsiConsole console, IRepositoryInitializer repositoryInitializer, + TextWriter? jsonOutput = null) + { + return new InitCommand(console, repositoryInitializer, new CliJsonWriter(jsonOutput ?? TextWriter.Null), + CliErrorMapperTests.CreateMapper()); + } + + internal static IRepositoryInitializer CreateInitializer(RepositoryInitResult result) + { + var initializer = A.Fake(); + A.CallTo(() => initializer.InitializeAsync(A._, A._)).Returns(result); + + return initializer; + } + + private static IRepositoryInitializer CreateThrowingInitializer(Exception exception) + { + var initializer = A.Fake(); + A.CallTo(() => initializer.InitializeAsync(A._, A._)).ThrowsAsync(exception); + + return initializer; + } + + /// + /// Asserts that the output is exactly one failure envelope with the specified error code and returns its message. + /// + private static string AssertErrorEnvelope(StringWriter jsonOutput, string expectedCode) + { + using var document = JsonDocument.Parse(jsonOutput.ToString()); + var root = document.RootElement; + + root.EnumerateObject().Select(x => x.Name) + .Should().Equal("ok", "data", "warnings", "error"); + + root.GetProperty("ok").GetBoolean() + .Should().BeFalse(); + + root.GetProperty("data").ValueKind + .Should().Be(JsonValueKind.Null); + + root.GetProperty("warnings").GetArrayLength() + .Should().Be(0); + + root.GetProperty("error").GetProperty("code").GetString() + .Should().Be(expectedCode); + + return root.GetProperty("error").GetProperty("message").GetString()!; + } +} diff --git a/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Version/VersionCommandTests.cs b/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Version/VersionCommandTests.cs index 930f2de..7f2e198 100644 --- a/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Version/VersionCommandTests.cs +++ b/tests/CreativeCoders.ShadeKit.Cli.Tests/Commands/Version/VersionCommandTests.cs @@ -221,7 +221,7 @@ public void VersionCommand_CliCommandAttribute_IsRegisteredAsTopLevelVersionComm attribute .Should().NotBeNull(); - attribute!.Commands + attribute.Commands .Should().Equal("version"); } diff --git a/tests/CreativeCoders.ShadeKit.Cli.Tests/CreativeCoders.ShadeKit.Cli.Tests.csproj b/tests/CreativeCoders.ShadeKit.Cli.Tests/CreativeCoders.ShadeKit.Cli.Tests.csproj index ea0f4b8..006be5d 100644 --- a/tests/CreativeCoders.ShadeKit.Cli.Tests/CreativeCoders.ShadeKit.Cli.Tests.csproj +++ b/tests/CreativeCoders.ShadeKit.Cli.Tests/CreativeCoders.ShadeKit.Cli.Tests.csproj @@ -8,6 +8,10 @@ + + + + diff --git a/tests/CreativeCoders.ShadeKit.Cli.Tests/CurrentDirectoryCollectionDefinition.cs b/tests/CreativeCoders.ShadeKit.Cli.Tests/CurrentDirectoryCollectionDefinition.cs index 8ff640e..faf8844 100644 --- a/tests/CreativeCoders.ShadeKit.Cli.Tests/CurrentDirectoryCollectionDefinition.cs +++ b/tests/CreativeCoders.ShadeKit.Cli.Tests/CurrentDirectoryCollectionDefinition.cs @@ -7,4 +7,8 @@ namespace CreativeCoders.ShadeKit.Cli.Tests; public sealed class CurrentDirectoryCollectionDefinition { public const string Name = "CurrentDirectory"; + + private CurrentDirectoryCollectionDefinition() + { + } } diff --git a/tests/CreativeCoders.ShadeKit.Cli.Tests/Output/CliCommandAbortTests.cs b/tests/CreativeCoders.ShadeKit.Cli.Tests/Output/CliCommandAbortTests.cs new file mode 100644 index 0000000..defa7e8 --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Cli.Tests/Output/CliCommandAbortTests.cs @@ -0,0 +1,249 @@ +using System.Text.Json; +using AwesomeAssertions; +using CreativeCoders.Cli.Hosting.Exceptions; +using CreativeCoders.ShadeKit.Cli.Output; +using CreativeCoders.ShadeKit.Core.Engine; +using CreativeCoders.ShadeKit.Core.Store; +using FakeItEasy; + +namespace CreativeCoders.ShadeKit.Cli.Tests.Output; + +public class CliCommandAbortTests +{ + private static readonly CliError Error = new(ShadeKitCliExitCodes.InvalidFile, ShadeKitCliErrorCodes.InvalidFile, + "'/kit/[nested]/config.json' is invalid: Broken."); + + [Fact] + public void Create_PlainMode_ReturnsPrintedAbortExceptionWithEscapedMessageAndWritesNoJson() + { + // Arrange + var cause = new InvalidOperationException("cause"); + var jsonWriter = A.Fake(); + + // Act + var exception = CliCommandAbort.Create(Error, cause, false, jsonWriter); + + // Assert + exception.Message + .Should().Be("'/kit/[[nested]]/config.json' is invalid: Broken."); + + exception.ExitCode + .Should().Be(ShadeKitCliExitCodes.InvalidFile); + + exception.PrintMessage + .Should().BeTrue(); + + exception.InnerException + .Should().BeSameAs(cause); + + A.CallTo(jsonWriter) + .MustNotHaveHappened(); + } + + [Fact] + public void Create_JsonMode_WritesFailureEnvelopeAndReturnsSilentAbortExceptionWithRawMessage() + { + // Arrange + var cause = new InvalidOperationException("cause"); + using var jsonOutput = new StringWriter(); + + // Act + var exception = CliCommandAbort.Create(Error, cause, true, new CliJsonWriter(jsonOutput)); + + // Assert + exception.Message + .Should().Be(Error.Message); + + exception.ExitCode + .Should().Be(ShadeKitCliExitCodes.InvalidFile); + + exception.PrintMessage + .Should().BeFalse(); + + exception.InnerException + .Should().BeSameAs(cause); + + using var document = JsonDocument.Parse(jsonOutput.ToString()); + var root = document.RootElement; + + root.GetProperty("ok").GetBoolean() + .Should().BeFalse(); + + root.GetProperty("error").GetProperty("code").GetString() + .Should().Be(ShadeKitCliErrorCodes.InvalidFile); + + root.GetProperty("error").GetProperty("message").GetString() + .Should().Be(Error.Message); + } + + [Fact] + public void Create_NullError_ThrowsArgumentNullException() + { + // Act + var act = () => CliCommandAbort.Create(null!, new InvalidOperationException(), true, A.Fake()); + + // Assert + act.Should().Throw() + .WithParameterName("error"); + } + + [Fact] + public void Create_NullException_ThrowsArgumentNullException() + { + // Act + var act = () => CliCommandAbort.Create(Error, null!, true, A.Fake()); + + // Assert + act.Should().Throw() + .WithParameterName("exception"); + } + + [Fact] + public void Create_NullJsonWriter_ThrowsArgumentNullException() + { + // Act + var act = () => CliCommandAbort.Create(Error, new InvalidOperationException(), false, null!); + + // Assert + act.Should().Throw() + .WithParameterName("jsonWriter"); + } + + [Fact] + public void Run_OperationSucceeds_ReturnsResultAndWritesNoJson() + { + // Arrange + var jsonWriter = A.Fake(); + + // Act + var result = CliCommandAbort.Run(() => 42, CreateMapper(), true, jsonWriter); + + // Assert + result + .Should().Be(42); + + A.CallTo(jsonWriter) + .MustNotHaveHappened(); + } + + [Fact] + public void Run_OperationThrowsMappedException_ThrowsAbortExceptionWithMappedExitCode() + { + // Arrange + var cause = new KitRootPathNotFoundException("/repo/src", "/repo"); + using var jsonOutput = new StringWriter(); + + // Act + var act = () => CliCommandAbort.Run(() => throw cause, CreateMapper(), true, + new CliJsonWriter(jsonOutput)); + + // Assert + var exception = act.Should().Throw().Which; + + exception.ExitCode + .Should().Be(ShadeKitCliExitCodes.KitRootNotFound); + + exception.InnerException + .Should().BeSameAs(cause); + + using var document = JsonDocument.Parse(jsonOutput.ToString()); + + document.RootElement.GetProperty("error").GetProperty("code").GetString() + .Should().Be(ShadeKitCliErrorCodes.KitRootNotFound); + } + + [Fact] + public void Run_OperationThrowsUnmappedException_LetsItEscapeAndWritesNoJson() + { + // Arrange + var cause = new InvalidOperationException("unexpected"); + var jsonWriter = A.Fake(); + + // Act + var act = () => CliCommandAbort.Run(() => throw cause, CreateMapper(), true, jsonWriter); + + // Assert + act.Should().Throw() + .Which.Should().BeSameAs(cause); + + A.CallTo(jsonWriter) + .MustNotHaveHappened(); + } + + [Fact] + public async Task RunAsync_OperationSucceeds_ReturnsResult() + { + // Act + var result = await CliCommandAbort.RunAsync(() => Task.FromResult("done"), CreateMapper(), false, + A.Fake()); + + // Assert + result + .Should().Be("done"); + } + + [Fact] + public async Task RunAsync_OperationFaultsWithMappedException_ThrowsPrintedAbortExceptionInPlainMode() + { + // Arrange + var cause = new NotAGitRepositoryException("/tmp/[x]"); + var jsonWriter = A.Fake(); + + // Act + var act = () => CliCommandAbort.RunAsync(() => Task.FromException(cause), CreateMapper(), false, + jsonWriter); + + // Assert + var exception = (await act.Should().ThrowAsync()).Which; + + exception.ExitCode + .Should().Be(ShadeKitCliExitCodes.NotAGitRepository); + + exception.PrintMessage + .Should().BeTrue(); + + exception.Message + .Should().Contain("'/tmp/[[x]]'"); + + A.CallTo(jsonWriter) + .MustNotHaveHappened(); + } + + [Fact] + public async Task RunAsync_OperationThrowsUnmappedException_LetsItEscape() + { + // Act + var act = () => CliCommandAbort.RunAsync(() => throw new InvalidOperationException("unexpected"), + CreateMapper(), true, A.Fake()); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public void Run_NullOperation_ThrowsArgumentNullException() + { + // Act + var act = () => CliCommandAbort.Run(null!, CreateMapper(), true, A.Fake()); + + // Assert + act.Should().Throw() + .WithParameterName("operation"); + } + + [Fact] + public async Task RunAsync_NullErrorMapper_ThrowsArgumentNullException() + { + // Act + var act = () => CliCommandAbort.RunAsync(() => Task.FromResult(1), null!, true, A.Fake()); + + // Assert + (await act.Should().ThrowAsync()) + .WithParameterName("errorMapper"); + } + + private static CliErrorMapper CreateMapper() + { + return new CliErrorMapper(A.Fake()); + } +} diff --git a/tests/CreativeCoders.ShadeKit.Cli.Tests/Output/CliErrorMapperTests.cs b/tests/CreativeCoders.ShadeKit.Cli.Tests/Output/CliErrorMapperTests.cs index 8103246..7f3d41a 100644 --- a/tests/CreativeCoders.ShadeKit.Cli.Tests/Output/CliErrorMapperTests.cs +++ b/tests/CreativeCoders.ShadeKit.Cli.Tests/Output/CliErrorMapperTests.cs @@ -2,7 +2,9 @@ using AwesomeAssertions; using CreativeCoders.ShadeKit.Cli.Output; using CreativeCoders.ShadeKit.Core.Engine; +using CreativeCoders.ShadeKit.Core.Files; using CreativeCoders.ShadeKit.Core.Formats; +using CreativeCoders.ShadeKit.Core.Init; using CreativeCoders.ShadeKit.Core.Store; using FakeItEasy; @@ -21,7 +23,10 @@ public void Map_KitRootNotFoundInRepository_ReturnsExitCode10WithInitHint() var error = mapper.Map(new KitRootPathNotFoundException("/repo/src", "/repo")); // Assert - error!.ExitCode + error + .Should().NotBeNull(); + + error.ExitCode .Should().Be(ShadeKitCliExitCodes.KitRootNotFound); error.Code @@ -43,7 +48,10 @@ public void Map_NotAGitRepository_ReturnsExitCode12NamingStartPathWithoutInitHin var error = mapper.Map(new NotAGitRepositoryException("/tmp/work")); // Assert - error!.ExitCode + error + .Should().NotBeNull(); + + error.ExitCode .Should().Be(ShadeKitCliExitCodes.NotAGitRepository); error.Code @@ -55,22 +63,6 @@ public void Map_NotAGitRepository_ReturnsExitCode12NamingStartPathWithoutInitHin .And.NotContain("shade-kit init", "init refuses to work outside a git repository"); } - [Fact] - public void Map_FeaturesDirectoryNotFound_ReturnsExitCode11NamingPath() - { - // Arrange - var mapper = CreateMapper(); - - // Act - var error = mapper.Map(new FeaturesDirectoryNotFoundException("/repo/.shadekit/features")); - - // Assert - error - .Should().Be(new CliError(ShadeKitCliExitCodes.FeaturesDirectoryNotFound, - ShadeKitCliErrorCodes.FeaturesDirectoryNotFound, - "The SHADE kit root directory contains no 'features' directory ('/repo/.shadekit/features').")); - } - [Fact] [Trait("Requirement", "FP01-AC4")] public void Map_FormatTooNewWithMinEngineVersion_ReturnsExitCode20WithRequiredAndInstalledVersion() @@ -98,7 +90,10 @@ public void Map_FormatTooNewWithoutMinEngineVersion_ReturnsExitCode20AskingForNe var error = mapper.Map(new FormatTooNewException(".shadekit/config.json", 2, 1, null)); // Assert - error!.ExitCode + error + .Should().NotBeNull(); + + error.ExitCode .Should().Be(ShadeKitCliExitCodes.FormatTooNew); error.Message @@ -142,6 +137,94 @@ public void Map_InvalidKitFile_ReturnsExitCode22WithReason() "'.shadekit/config.json' is invalid: The 'schemaVersion' field is missing.")); } + [Fact] + [Trait("Requirement", "FP02-AC6")] + public void Map_KitAlreadyInitialized_ReturnsExitCode13NamingKitRootWithUpgradeHint() + { + // Arrange + var mapper = CreateMapper(); + + // Act + var error = mapper.Map(new KitAlreadyInitializedException("/repo/.shadekit")); + + // Assert + error + .Should().Be(new CliError(ShadeKitCliExitCodes.KitAlreadyInitialized, + ShadeKitCliErrorCodes.KitAlreadyInitialized, + "SHADE kit is already initialised in this repository: '/repo/.shadekit' exists. Run " + + "'shade-kit upgrade' to update it.")); + } + + [Fact] + public void Map_InvalidManagedSection_ReturnsExitCode23NamingFileReasonAndMarkerFix() + { + // Arrange + var mapper = CreateMapper(); + + // Act + var error = mapper.Map(new InvalidManagedSectionException("/repo/CLAUDE.md", + "The begin marker in line 3 has no end marker.")); + + // Assert + error + .Should().Be(new CliError(ShadeKitCliExitCodes.InvalidManagedSection, + ShadeKitCliErrorCodes.InvalidManagedSection, + "The SHADE kit section markers in '/repo/CLAUDE.md' are invalid: The begin marker in line 3 has no " + + "end marker. Fix the file so that every code block is closed and the file contains exactly one " + + "'' line followed by exactly one '' line outside " + + "code blocks, then run the command again.")); + } + + [Fact] + public void Map_UnsupportedFileEncoding_ReturnsExitCode24NamingFileEncodingAndConversionHint() + { + // Arrange + var mapper = CreateMapper(); + + // Act + var error = mapper.Map(new UnsupportedFileEncodingException("/repo/.gitignore", "UTF-16 LE")); + + // Assert + error + .Should().Be(new CliError(ShadeKitCliExitCodes.UnsupportedFileEncoding, + ShadeKitCliErrorCodes.UnsupportedFileEncoding, + "'/repo/.gitignore' is encoded as UTF-16 LE, but shade-kit can only change UTF-8 files. Convert " + + "the file to UTF-8, then run the command again.")); + } + + [Fact] + public void Map_PathNotAFile_ReturnsExitCode14NamingPath() + { + // Arrange + var mapper = CreateMapper(); + + // Act + var error = mapper.Map(new PathNotAFileException("/repo/CLAUDE.md")); + + // Assert + error + .Should().Be(new CliError(ShadeKitCliExitCodes.PathNotAFile, ShadeKitCliErrorCodes.PathNotAFile, + "'/repo/CLAUDE.md' is a directory, but shade-kit must write a file at this path. Move or remove " + + "the directory, then run the command again.")); + } + + [Fact] + public void Map_KitRootNotADirectory_ReturnsExitCode15NamingPathWithInitHint() + { + // Arrange + var mapper = CreateMapper(); + + // Act + var error = mapper.Map(new KitRootNotADirectoryException("/repo/.shadekit")); + + // Assert + error + .Should().Be(new CliError(ShadeKitCliExitCodes.KitRootNotADirectory, + ShadeKitCliErrorCodes.KitRootNotADirectory, + "'/repo/.shadekit' is a file, but SHADE kit needs a '.shadekit' directory at the repository root. " + + "Move or remove the file, then run 'shade-kit init'.")); + } + [Fact] public void Map_UnexpectedException_ReturnsNull() { @@ -278,7 +361,10 @@ public void Map_InvalidKitFileWithInnerException_ReturnsExitCode22() new System.Text.Json.JsonException("inner"))); // Assert - error!.ExitCode + error + .Should().NotBeNull(); + + error.ExitCode .Should().Be(ShadeKitCliExitCodes.InvalidFile); } } diff --git a/tests/CreativeCoders.ShadeKit.Cli.Tests/Output/CliJsonWriterTests.cs b/tests/CreativeCoders.ShadeKit.Cli.Tests/Output/CliJsonWriterTests.cs index 884d2b5..779df42 100644 --- a/tests/CreativeCoders.ShadeKit.Cli.Tests/Output/CliJsonWriterTests.cs +++ b/tests/CreativeCoders.ShadeKit.Cli.Tests/Output/CliJsonWriterTests.cs @@ -1,6 +1,8 @@ using System.Text.Json; using AwesomeAssertions; using CreativeCoders.ShadeKit.Cli.Output; +using CreativeCoders.ShadeKit.Core.Config; +using CreativeCoders.ShadeKit.Core.Init; using FakeItEasy; namespace CreativeCoders.ShadeKit.Cli.Tests.Output; @@ -315,4 +317,41 @@ public sealed class CyclicData { public CyclicData? Self { get; set; } } + + [Fact] + public void Write_DataWithEnumValue_WritesLowerCaseKebabCaseName() + { + // Arrange + using var output = new StringWriter(); + var writer = new CliJsonWriter(output); + + // Act + writer.Write(CliJsonEnvelope.Success(new { Policy = OutdatedFormatOnReadPolicy.MigrateAndWarn })); + + // Assert + using var document = JsonDocument.Parse(output.ToString()); + + document.RootElement.GetProperty("data").GetProperty("policy").GetString() + .Should().Be("migrate-and-warn"); + } + + [Theory] + [InlineData(InitializedFileAction.Created, "created")] + [InlineData(InitializedFileAction.Appended, "appended")] + [InlineData(InitializedFileAction.Updated, "updated")] + public void GetEnumName_FileAction_ReturnsNameThatJsonOutputUses(InitializedFileAction action, string expectedName) + { + // Arrange + using var output = new StringWriter(); + new CliJsonWriter(output).Write(CliJsonEnvelope.Success(new { Action = action })); + using var document = JsonDocument.Parse(output.ToString()); + + // Act + var name = CliJsonWriter.GetEnumName(action); + + // Assert + name + .Should().Be(expectedName) + .And.Be(document.RootElement.GetProperty("data").GetProperty("action").GetString()); + } } diff --git a/tests/CreativeCoders.ShadeKit.Cli.Tests/ProgramTests.cs b/tests/CreativeCoders.ShadeKit.Cli.Tests/ProgramTests.cs index 353ad0a..3a22b78 100644 --- a/tests/CreativeCoders.ShadeKit.Cli.Tests/ProgramTests.cs +++ b/tests/CreativeCoders.ShadeKit.Cli.Tests/ProgramTests.cs @@ -2,15 +2,16 @@ using System.Text; using System.Text.Json; using AwesomeAssertions; +using CreativeCoders.ShadeKit.Tests.Shared; namespace CreativeCoders.ShadeKit.Cli.Tests; [Collection(CurrentDirectoryCollectionDefinition.Name)] public sealed class ProgramTests : IDisposable { - private readonly string _originalDirectory = Directory.GetCurrentDirectory(); + private readonly CurrentDirectoryScope _currentDirectory = new(); - private readonly DirectoryInfo _tempDirectory = Directory.CreateTempSubdirectory("shade-kit-program-tests-"); + private readonly TempKitDirectory _tempDirectory = new("shade-kit-program-tests-"); [Fact] [Trait("Requirement", "FP01-AC7")] @@ -257,11 +258,10 @@ public async Task RunAsync_UnexpectedExceptionJson_WritesHostErrorEnvelopeAndDet Assert.SkipWhen(Environment.IsPrivilegedProcess, "A privileged process (root) can read a directory with file mode 000."); - Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, ".git")); - var featuresPath = Directory.CreateDirectory( - Path.Combine(_tempDirectory.FullName, ".shadekit", "features")).FullName; + _tempDirectory.CreateGitRoot(); + var featuresPath = _tempDirectory.CreateDirectory(".shadekit", "features"); File.SetUnixFileMode(featuresPath, UnixFileMode.None); - Directory.SetCurrentDirectory(_tempDirectory.FullName); + Directory.SetCurrentDirectory(_tempDirectory.RootPath); using var output = new StringWriter(); using var error = new StringWriter(); @@ -269,7 +269,7 @@ public async Task RunAsync_UnexpectedExceptionJson_WritesHostErrorEnvelopeAndDet try { // Act - var exitCode = await Program.RunAsync(["features", "list", "--json"], output, error); + var exitCode = await Program.RunAsync(["feature", "list", "--json"], output, error); // Assert exitCode @@ -315,17 +315,17 @@ public async Task RunAsync_UnknownCommandPlain_ReturnsHostErrorWithoutJson() [Fact] [Trait("Requirement", "FP01-AC2")] [Trait("Requirement", "FP01-AC8")] - public async Task RunAsync_FeaturesListJsonInRepositoryWithoutKitRoot_WritesKitRootNotFoundEnvelopeAndReturns10() + public async Task RunAsync_FeatureListJsonInRepositoryWithoutKitRoot_WritesKitRootNotFoundEnvelopeAndReturns10() { // Arrange - Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, ".git")); - Directory.SetCurrentDirectory(_tempDirectory.FullName); + _tempDirectory.CreateGitRoot(); + Directory.SetCurrentDirectory(_tempDirectory.RootPath); using var output = new StringWriter(); using var error = new StringWriter(); // Act - var exitCode = await Program.RunAsync(["features", "list", "--json"], output, error); + var exitCode = await Program.RunAsync(["feature", "list", "--json"], output, error); // Assert exitCode @@ -345,19 +345,19 @@ public async Task RunAsync_FeaturesListJsonInRepositoryWithoutKitRoot_WritesKitR [Fact] [Trait("Requirement", "FP01-AC1")] - public async Task RunAsync_FeaturesListJsonInSubdirectoryOfInitialisedRepository_ListsFeaturesOfRepositoryRoot() + public async Task RunAsync_FeatureListJsonInSubdirectoryOfInitialisedRepository_ListsFeaturesOfRepositoryRoot() { // Arrange - Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, ".git")); - Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, ".shadekit", "features", "auth")); - var nestedPath = Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, "src", "app")).FullName; + _tempDirectory.CreateGitRoot(); + _tempDirectory.CreateDirectory(".shadekit", "features", "auth"); + var nestedPath = _tempDirectory.CreateDirectory("src", "app"); Directory.SetCurrentDirectory(nestedPath); using var output = new StringWriter(); using var error = new StringWriter(); // Act - var exitCode = await Program.RunAsync(["features", "list", "--json"], output, error); + var exitCode = await Program.RunAsync(["feature", "list", "--json"], output, error); // Assert exitCode @@ -371,17 +371,17 @@ public async Task RunAsync_FeaturesListJsonInSubdirectoryOfInitialisedRepository [Fact] [Trait("Requirement", "FP01-AC2")] - public async Task RunAsync_FeaturesListPlainInRepositoryWithoutKitRoot_PrintsInitHintAndReturns10() + public async Task RunAsync_FeatureListPlainInRepositoryWithoutKitRoot_PrintsInitHintAndReturns10() { // Arrange - Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, ".git")); - Directory.SetCurrentDirectory(_tempDirectory.FullName); + _tempDirectory.CreateGitRoot(); + Directory.SetCurrentDirectory(_tempDirectory.RootPath); using var output = new StringWriter(); using var error = new StringWriter(); // Act - var exitCode = await Program.RunAsync(["features", "list"], output, error); + var exitCode = await Program.RunAsync(["feature", "list"], output, error); // Assert exitCode @@ -476,47 +476,72 @@ public async Task RunAsync_VersionJson_WritesExactlyOneDocumentFollowedByOneLine } [Fact] - [Trait("Requirement", "FP01-AC8")] - public async Task RunAsync_FeaturesListJsonWithoutFeaturesDirectory_WritesFeaturesDirEnvelopeAndReturns11() + [Trait("Requirement", "FP01-AC12")] + public async Task RunAsync_FeatureListJsonWithoutFeaturesDirectory_WritesEmptyFeatureListAndReturns0() { // Arrange - Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, ".git")); - Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, ".shadekit")); - Directory.SetCurrentDirectory(_tempDirectory.FullName); + _tempDirectory.CreateGitRoot(); + _tempDirectory.CreateDirectory(".shadekit"); + Directory.SetCurrentDirectory(_tempDirectory.RootPath); using var output = new StringWriter(); using var error = new StringWriter(); // Act - var exitCode = await Program.RunAsync(["features", "list", "--json"], output, error); + var exitCode = await Program.RunAsync(["feature", "list", "--json"], output, error); // Assert exitCode - .Should().Be(ShadeKitCliExitCodes.FeaturesDirectoryNotFound); + .Should().Be(0); using var document = JsonDocument.Parse(output.ToString()); - document.RootElement.GetProperty("error").GetProperty("code").GetString() - .Should().Be(ShadeKitCliErrorCodes.FeaturesDirectoryNotFound); + document.RootElement.GetProperty("ok").GetBoolean() + .Should().BeTrue(); + + document.RootElement.GetProperty("data").GetProperty("features").GetArrayLength() + .Should().Be(0); error.ToString() - .Should().BeEmpty(); + .Should().BeNullOrWhiteSpace("the host writes only its empty footer line to standard error"); + } + + [Fact] + public async Task RunAsync_FeatureListPlainWithoutFeaturesDirectory_PrintsNoFeaturesHintAndReturns0() + { + // Arrange + _tempDirectory.CreateGitRoot(); + _tempDirectory.CreateDirectory(".shadekit"); + Directory.SetCurrentDirectory(_tempDirectory.RootPath); + + using var output = new StringWriter(); + using var error = new StringWriter(); + + // Act + var exitCode = await Program.RunAsync(["feature", "list"], output, error); + + // Assert + exitCode + .Should().Be(0); + + output.ToString() + .Should().Contain("No features found."); } [Fact] - public async Task RunAsync_FeaturesListPlainInInitialisedRepository_PrintsNamesInOrdinalOrder() + public async Task RunAsync_FeatureListPlainInInitialisedRepository_PrintsNamesInOrdinalOrder() { // Arrange - Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, ".git")); - Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, ".shadekit", "features", "login")); - Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, ".shadekit", "features", "Billing")); - Directory.SetCurrentDirectory(_tempDirectory.FullName); + _tempDirectory.CreateGitRoot(); + _tempDirectory.CreateDirectory(".shadekit", "features", "login"); + _tempDirectory.CreateDirectory(".shadekit", "features", "Billing"); + Directory.SetCurrentDirectory(_tempDirectory.RootPath); using var output = new StringWriter(); using var error = new StringWriter(); // Act - var exitCode = await Program.RunAsync(["features", "list"], output, error); + var exitCode = await Program.RunAsync(["feature", "list"], output, error); // Assert exitCode @@ -535,11 +560,10 @@ public async Task RunAsync_UnexpectedExceptionPlain_ReturnsHostErrorWithDetailsO Assert.SkipWhen(Environment.IsPrivilegedProcess, "A privileged process (root) can read a directory with file mode 000."); - Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, ".git")); - var featuresPath = Directory.CreateDirectory( - Path.Combine(_tempDirectory.FullName, ".shadekit", "features")).FullName; + _tempDirectory.CreateGitRoot(); + var featuresPath = _tempDirectory.CreateDirectory(".shadekit", "features"); File.SetUnixFileMode(featuresPath, UnixFileMode.None); - Directory.SetCurrentDirectory(_tempDirectory.FullName); + Directory.SetCurrentDirectory(_tempDirectory.RootPath); using var output = new StringWriter(); using var error = new StringWriter(); @@ -547,7 +571,7 @@ public async Task RunAsync_UnexpectedExceptionPlain_ReturnsHostErrorWithDetailsO try { // Act - var exitCode = await Program.RunAsync(["features", "list"], output, error); + var exitCode = await Program.RunAsync(["feature", "list"], output, error); // Assert exitCode @@ -625,8 +649,8 @@ public async Task RunAsync_PlainModeWithBrokenStandardOutputAndError_ReturnsHost public void Dispose() { - Directory.SetCurrentDirectory(_originalDirectory); - _tempDirectory.Delete(true); + _currentDirectory.Dispose(); + _tempDirectory.Dispose(); } /// diff --git a/tests/CreativeCoders.ShadeKit.Cli.Tests/ShadeKitCliErrorCodesTests.cs b/tests/CreativeCoders.ShadeKit.Cli.Tests/ShadeKitCliErrorCodesTests.cs index e9ef436..320efce 100644 --- a/tests/CreativeCoders.ShadeKit.Cli.Tests/ShadeKitCliErrorCodesTests.cs +++ b/tests/CreativeCoders.ShadeKit.Cli.Tests/ShadeKitCliErrorCodesTests.cs @@ -8,11 +8,15 @@ public partial class ShadeKitCliErrorCodesTests [Theory] [Trait("Requirement", "FP01-AC8")] [InlineData(ShadeKitCliErrorCodes.KitRootNotFound, "kit-root-not-found")] - [InlineData(ShadeKitCliErrorCodes.FeaturesDirectoryNotFound, "features-dir-not-found")] [InlineData(ShadeKitCliErrorCodes.NotAGitRepository, "not-a-git-repository")] + [InlineData(ShadeKitCliErrorCodes.KitAlreadyInitialized, "already-initialized")] + [InlineData(ShadeKitCliErrorCodes.PathNotAFile, "path-not-a-file")] + [InlineData(ShadeKitCliErrorCodes.KitRootNotADirectory, "kit-root-not-a-directory")] [InlineData(ShadeKitCliErrorCodes.FormatTooNew, "format-too-new")] [InlineData(ShadeKitCliErrorCodes.UpgradeNeeded, "upgrade-needed")] [InlineData(ShadeKitCliErrorCodes.InvalidFile, "invalid-file")] + [InlineData(ShadeKitCliErrorCodes.InvalidManagedSection, "invalid-managed-section")] + [InlineData(ShadeKitCliErrorCodes.UnsupportedFileEncoding, "unsupported-encoding")] [InlineData(ShadeKitCliErrorCodes.HostError, "host-error")] public void ErrorCodes_Contract_HaveStableValues(string errorCode, string expectedErrorCode) { diff --git a/tests/CreativeCoders.ShadeKit.Cli.Tests/ShadeKitCliExitCodesTests.cs b/tests/CreativeCoders.ShadeKit.Cli.Tests/ShadeKitCliExitCodesTests.cs index 961fb67..91cadb3 100644 --- a/tests/CreativeCoders.ShadeKit.Cli.Tests/ShadeKitCliExitCodesTests.cs +++ b/tests/CreativeCoders.ShadeKit.Cli.Tests/ShadeKitCliExitCodesTests.cs @@ -27,11 +27,15 @@ public void ToProcessExitCode_NegativeHostExitCode_ReturnsHostError(int exitCode [Theory] [InlineData(CliExitCodes.Success)] [InlineData(ShadeKitCliExitCodes.KitRootNotFound)] - [InlineData(ShadeKitCliExitCodes.FeaturesDirectoryNotFound)] [InlineData(ShadeKitCliExitCodes.NotAGitRepository)] + [InlineData(ShadeKitCliExitCodes.KitAlreadyInitialized)] + [InlineData(ShadeKitCliExitCodes.PathNotAFile)] + [InlineData(ShadeKitCliExitCodes.KitRootNotADirectory)] [InlineData(ShadeKitCliExitCodes.FormatTooNew)] [InlineData(ShadeKitCliExitCodes.UpgradeNeeded)] [InlineData(ShadeKitCliExitCodes.InvalidFile)] + [InlineData(ShadeKitCliExitCodes.InvalidManagedSection)] + [InlineData(ShadeKitCliExitCodes.UnsupportedFileEncoding)] [InlineData(ShadeKitCliExitCodes.HostError)] public void ToProcessExitCode_NonNegativeExitCode_ReturnsExitCodeUnchanged(int exitCode) { @@ -46,11 +50,15 @@ public void ToProcessExitCode_NonNegativeExitCode_ReturnsExitCodeUnchanged(int e [Theory] [Trait("Requirement", "FP01-AC8")] [InlineData(ShadeKitCliExitCodes.KitRootNotFound, 10)] - [InlineData(ShadeKitCliExitCodes.FeaturesDirectoryNotFound, 11)] [InlineData(ShadeKitCliExitCodes.NotAGitRepository, 12)] + [InlineData(ShadeKitCliExitCodes.KitAlreadyInitialized, 13)] + [InlineData(ShadeKitCliExitCodes.PathNotAFile, 14)] + [InlineData(ShadeKitCliExitCodes.KitRootNotADirectory, 15)] [InlineData(ShadeKitCliExitCodes.FormatTooNew, 20)] [InlineData(ShadeKitCliExitCodes.UpgradeNeeded, 21)] [InlineData(ShadeKitCliExitCodes.InvalidFile, 22)] + [InlineData(ShadeKitCliExitCodes.InvalidManagedSection, 23)] + [InlineData(ShadeKitCliExitCodes.UnsupportedFileEncoding, 24)] [InlineData(ShadeKitCliExitCodes.HostError, 255)] public void ExitCodes_Contract_HaveStableValues(int exitCode, int expectedExitCode) { diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Config/KitConfigTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Config/KitConfigTests.cs index 0a46cb5..e5a6482 100644 --- a/tests/CreativeCoders.ShadeKit.Core.Tests/Config/KitConfigTests.cs +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Config/KitConfigTests.cs @@ -61,11 +61,12 @@ public void SerializeAfterDeserialize_ConfigWithFieldsOfLaterPackages_KeepsThemU { "schemaVersion": 1, "minEngineVersion": "0.1.0", - "kitVersion": "1.4.0", "defaultProfile": "feature", "outdatedFormatOnRead": "stop", "trackers": { "github.com": "gh" }, - "coverageThreshold": 80.0 + "report": "ask", + "laterPackageThreshold": 80.0, + "laterPackageSettings": { "github.com": [ "gh", null, true ] } } """; @@ -79,7 +80,56 @@ public void SerializeAfterDeserialize_ConfigWithFieldsOfLaterPackages_KeepsThemU JsonNode.DeepEquals(JsonNode.Parse(json), JsonNode.Parse(original)) .Should().BeTrue($"the written config must keep every field, but was:{Environment.NewLine}{json}"); - json.Should().Contain("\"coverageThreshold\": 80.0"); + json.Should().Contain("\"laterPackageThreshold\": 80.0"); + } + + [Fact] + [Trait("Requirement", "FP01-AC3")] + public void SerializeAfterDeserialize_KnownFieldsChanged_KeepsUnknownFields() + { + // Arrange + const string original = + """ + { + "schemaVersion": 1, + "minEngineVersion": "0.1.0", + "kitVersion": "1.0.0", + "laterPackageSettings": { "mode": "strict", "limit": 1.50 } + } + """; + + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + var config = serializer.Deserialize(original, FormatIds.Config, FilePath, false).Document; + + config.KitVersion = "2.0.0"; + config.Report = ReportPolicy.Never; + + // Act + var json = serializer.Serialize(config, FormatIds.Config); + + // Assert + var written = JsonNode.Parse(json); + + written + .Should().NotBeNull(); + + var kitVersionNode = written["kitVersion"]; + + kitVersionNode + .Should().NotBeNull(); + + kitVersionNode.GetValue() + .Should().Be("2.0.0"); + + var reportNode = written["report"]; + + reportNode + .Should().NotBeNull(); + + reportNode.GetValue() + .Should().Be("never"); + + json.Should().Contain("\"laterPackageSettings\": {\n \"mode\": \"strict\",\n \"limit\": 1.50\n }"); } [Fact] @@ -93,7 +143,391 @@ public void Serialize_StopPolicy_WritesKebabCaseValue() var json = serializer.Serialize(config, FormatIds.Config); // Assert - JsonNode.Parse(json)!["outdatedFormatOnRead"]!.GetValue() + var root = JsonNode.Parse(json); + + root + .Should().NotBeNull(); + + var outdatedFormatOnReadNode = root["outdatedFormatOnRead"]; + + outdatedFormatOnReadNode + .Should().NotBeNull(); + + outdatedFormatOnReadNode.GetValue() .Should().Be("stop"); } + + [Fact] + public void Serialize_InitContent_WritesV1FieldsInContractOrder() + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + var config = new KitConfig + { + KitVersion = "1.2.3", + DefaultProfile = "feature", + OutdatedFormatOnRead = OutdatedFormatOnReadPolicy.MigrateAndWarn, + Trackers = new Dictionary { ["github.com"] = "gh" }, + Report = ReportPolicy.Ask + }; + + // Act + var json = serializer.Serialize(config, FormatIds.Config); + + // Assert + json + .Should().Be( + """ + { + "schemaVersion": 1, + "minEngineVersion": "0.1.0", + "kitVersion": "1.2.3", + "defaultProfile": "feature", + "outdatedFormatOnRead": "migrate-and-warn", + "trackers": { + "github.com": "gh" + }, + "report": "ask" + } + + """.ReplaceLineEndings("\n")); + } + + [Fact] + public void Serialize_AllV1FieldsSet_WritesCoverageThresholdLast() + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + var config = new KitConfig + { + KitVersion = "1.2.3", + Trackers = new Dictionary { ["github.com"] = "gh" }, + Report = ReportPolicy.Never, + CoverageThreshold = 80.5 + }; + + // Act + var json = serializer.Serialize(config, FormatIds.Config); + + // Assert + var root = JsonNode.Parse(json); + + root + .Should().NotBeNull(); + + root.AsObject().Select(field => field.Key) + .Should().Equal("schemaVersion", "minEngineVersion", "kitVersion", "defaultProfile", + "outdatedFormatOnRead", "trackers", "report", "coverageThreshold"); + } + + [Fact] + public void Serialize_DefaultConfig_WritesDefaultsAndOmitsOptionalFields() + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + var config = new KitConfig(); + + // Act + var json = serializer.Serialize(config, FormatIds.Config); + + // Assert + var root = JsonNode.Parse(json); + + root + .Should().NotBeNull(); + + root.AsObject().Select(field => field.Key) + .Should().Equal("schemaVersion", "minEngineVersion", "defaultProfile", "outdatedFormatOnRead", "trackers", + "report"); + } + + [Fact] + public void Deserialize_OnlyVersionFields_UsesDefaults() + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var config = serializer.Deserialize("""{ "schemaVersion": 1, "minEngineVersion": "0.1.0" }""", + FormatIds.Config, FilePath, false).Document; + + // Assert + config.KitVersion + .Should().BeNull(); + + config.DefaultProfile + .Should().Be("feature"); + + config.Trackers + .Should().Equal(new Dictionary { ["github.com"] = "gh" }); + + config.Report + .Should().Be(ReportPolicy.Ask); + + config.CoverageThreshold + .Should().BeNull(); + + config.ExtensionData + .Should().BeNull(); + } + + [Fact] + public void Deserialize_AllV1Fields_ReadsValues() + { + // Arrange + const string json = + """ + { + "schemaVersion": 1, + "minEngineVersion": "0.1.0", + "kitVersion": "1.4.0", + "defaultProfile": "bugfix", + "outdatedFormatOnRead": "stop", + "trackers": { "github.com": "gh", "jira.example.com": "PROJ" }, + "report": "always", + "coverageThreshold": 75.5 + } + """; + + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var config = serializer.Deserialize(json, FormatIds.Config, FilePath, false).Document; + + // Assert + config.KitVersion + .Should().Be("1.4.0"); + + config.DefaultProfile + .Should().Be("bugfix"); + + config.OutdatedFormatOnRead + .Should().Be(OutdatedFormatOnReadPolicy.Stop); + + config.Trackers + .Should().BeEquivalentTo(new Dictionary + { + ["github.com"] = "gh", + ["jira.example.com"] = "PROJ" + }); + + config.Report + .Should().Be(ReportPolicy.Always); + + config.CoverageThreshold + .Should().Be(75.5); + + config.ExtensionData + .Should().BeNull(); + } + + [Fact] + public void SerializeAfterDeserialize_TrackerHostsWithUpperCaseAndDots_KeepsKeysVerbatim() + { + // Arrange + const string original = + """ + { + "schemaVersion": 1, + "minEngineVersion": "0.1.0", + "trackers": { "GitHub.com": "gh", "Jira.Example.com": "PROJ" } + } + """; + + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var config = serializer.Deserialize(original, FormatIds.Config, FilePath, false).Document; + var json = serializer.Serialize(config, FormatIds.Config); + + // Assert + var root = JsonNode.Parse(json); + + root + .Should().NotBeNull(); + + var trackersNode = root["trackers"]; + + trackersNode + .Should().NotBeNull(); + + trackersNode.AsObject().Select(tracker => tracker.Key) + .Should().Equal("GitHub.com", "Jira.Example.com"); + } + + [Theory] + [InlineData(""", "report": "ask" """, ReportPolicy.Ask)] + [InlineData(""", "report": "always" """, ReportPolicy.Always)] + [InlineData(""", "report": "never" """, ReportPolicy.Never)] + [InlineData(""", "report": "Always" """, ReportPolicy.Always)] + [InlineData(""", "report": "NEVER" """, ReportPolicy.Never)] + [InlineData("", ReportPolicy.Ask)] + public void Deserialize_Report_ReadsPolicy(string reportField, ReportPolicy expected) + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var config = serializer.Deserialize( + $$"""{ "schemaVersion": 1, "minEngineVersion": "0.1.0"{{reportField}} }""", FormatIds.Config, FilePath, + false).Document; + + // Assert + config.Report + .Should().Be(expected); + } + + [Theory] + [InlineData(ReportPolicy.Ask, "ask")] + [InlineData(ReportPolicy.Always, "always")] + [InlineData(ReportPolicy.Never, "never")] + public void Serialize_Report_WritesKebabCaseValue(ReportPolicy report, string expected) + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + var config = new KitConfig { Report = report }; + + // Act + var json = serializer.Serialize(config, FormatIds.Config); + + // Assert + var root = JsonNode.Parse(json); + + root + .Should().NotBeNull(); + + var reportNode = root["report"]; + + reportNode + .Should().NotBeNull(); + + reportNode.GetValue() + .Should().Be(expected); + } + + [Theory] + [InlineData("\"sometimes\"")] + [InlineData("\"\"")] + [InlineData("0")] + [InlineData("1")] + [InlineData("null")] + [InlineData("true")] + public void Deserialize_InvalidReport_ThrowsInvalidKitFileException(string value) + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var act = () => serializer.Deserialize( + $$"""{ "schemaVersion": 1, "minEngineVersion": "0.1.0", "report": {{value}} }""", + FormatIds.Config, FilePath, false); + + // Assert + act.Should().Throw(); + } + + [Theory] + [InlineData(""" "defaultProfile": null """)] + [InlineData(""" "trackers": null """)] + [InlineData(""" "defaultProfile": 1 """)] + [InlineData(""" "kitVersion": 1 """)] + [InlineData(""" "trackers": [ "github.com" ] """)] + [InlineData(""" "trackers": { "github.com": 1 } """)] + [InlineData(""" "coverageThreshold": "80" """)] + [InlineData(""" "coverageThreshold": 1e400 """)] + [InlineData(""" "coverageThreshold": -1e400 """)] + [InlineData(""" "trackers": { "github.com": "gh", "github.com": "gh2" } """)] + public void Deserialize_V1FieldWithInvalidValue_ThrowsInvalidKitFileException(string field) + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var act = () => serializer.Deserialize( + $$"""{ "schemaVersion": 1, "minEngineVersion": "0.1.0", {{field}} }""", FormatIds.Config, FilePath, + false); + + // Assert + act.Should().Throw(); + } + + [Theory] + [InlineData(""" "kitVersion": null """)] + [InlineData(""" "coverageThreshold": null """)] + public void Deserialize_OptionalFieldNull_ReadsNull(string field) + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var config = serializer.Deserialize( + $$"""{ "schemaVersion": 1, "minEngineVersion": "0.1.0", {{field}} }""", FormatIds.Config, FilePath, + false).Document; + + // Assert + new object?[] { config.KitVersion, config.CoverageThreshold } + .Should().AllSatisfy(value => value.Should().BeNull()); + } + + [Fact] + public void Deserialize_TrackerWithNullPrefix_ThrowsInvalidKitFileException() + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var act = () => serializer.Deserialize( + """{ "schemaVersion": 1, "minEngineVersion": "0.1.0", "trackers": { "github.com": null } }""", + FormatIds.Config, FilePath, false); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Deserialize_EmptyTrackers_ReadsEmptyMapping() + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var config = serializer.Deserialize( + """{ "schemaVersion": 1, "minEngineVersion": "0.1.0", "trackers": {} }""", FormatIds.Config, FilePath, + false).Document; + + // Assert + config.Trackers + .Should().BeEmpty(); + } + + [Fact] + public void Deserialize_TrackersWithoutGitHub_ReadsOnlyTheGivenHosts() + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var config = serializer.Deserialize( + """{ "schemaVersion": 1, "minEngineVersion": "0.1.0", "trackers": { "jira.example.com": "jira" } }""", + FormatIds.Config, FilePath, false).Document; + + // Assert + config.Trackers + .Should().Equal(new Dictionary { ["jira.example.com"] = "jira" }); + } + + [Fact] + public void Constructor_TwoInstances_DoNotShareTheDefaultTrackers() + { + // Arrange + var first = new KitConfig(); + var second = new KitConfig(); + + // Act + first.Trackers["jira.example.com"] = "jira"; + + // Assert + second.Trackers + .Should().Equal(new Dictionary { ["github.com"] = "gh" }); + } } diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/CreativeCoders.ShadeKit.Core.Tests.csproj b/tests/CreativeCoders.ShadeKit.Core.Tests/CreativeCoders.ShadeKit.Core.Tests.csproj index 1e4dbd8..2cac74c 100644 --- a/tests/CreativeCoders.ShadeKit.Core.Tests/CreativeCoders.ShadeKit.Core.Tests.csproj +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/CreativeCoders.ShadeKit.Core.Tests.csproj @@ -8,6 +8,10 @@ + + + + diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/CurrentDirectoryCollectionDefinition.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/CurrentDirectoryCollectionDefinition.cs index d463dba..bc695c1 100644 --- a/tests/CreativeCoders.ShadeKit.Core.Tests/CurrentDirectoryCollectionDefinition.cs +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/CurrentDirectoryCollectionDefinition.cs @@ -7,4 +7,8 @@ namespace CreativeCoders.ShadeKit.Core.Tests; public sealed class CurrentDirectoryCollectionDefinition { public const string Name = "CurrentDirectory"; + + private CurrentDirectoryCollectionDefinition() + { + } } diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Files/PathNotAFileExceptionTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Files/PathNotAFileExceptionTests.cs new file mode 100644 index 0000000..a7f8776 --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Files/PathNotAFileExceptionTests.cs @@ -0,0 +1,21 @@ +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Files; + +namespace CreativeCoders.ShadeKit.Core.Tests.Files; + +public class PathNotAFileExceptionTests +{ + [Fact] + public void Constructor_Path_MessageNamesPath() + { + // Act + var exception = new PathNotAFileException("/repo/CLAUDE.md"); + + // Assert + exception.Message + .Should().Be("The path '/repo/CLAUDE.md' is a directory, but a file is expected."); + + exception.Path + .Should().Be("/repo/CLAUDE.md"); + } +} diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Files/TextFileContentTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Files/TextFileContentTests.cs new file mode 100644 index 0000000..33049c1 --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Files/TextFileContentTests.cs @@ -0,0 +1,427 @@ +using System.Text; +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Files; +using CreativeCoders.ShadeKit.Tests.Shared; + +namespace CreativeCoders.ShadeKit.Core.Tests.Files; + +public class TextFileContentTests +{ + private const string FileName = "notes.txt"; + + private const string UnmarkedWideEncodingName = "UTF-16 or UTF-32 without byte order mark"; + + public static TheoryData UnsupportedEncodingFiles { get; } = new() + { + { [0xFF, 0xFE, (byte)'a', 0x00, (byte)'\n', 0x00], "UTF-16 LE" }, + { [0xFE, 0xFF, 0x00, (byte)'a', 0x00, (byte)'\n'], "UTF-16 BE" }, + { [0xFF, 0xFE, 0x00, 0x00, (byte)'a', 0x00, 0x00, 0x00], "UTF-32 LE" }, + { [0x00, 0x00, 0xFE, 0xFF, 0x00, 0x00, 0x00, (byte)'a'], "UTF-32 BE" }, + { [0xFF, 0xFE], "UTF-16 LE" }, + { [0xFE, 0xFF], "UTF-16 BE" }, + { [0xFF, 0xFE, 0x00, 0x00], "UTF-32 LE" }, + { [0x00, 0x00, 0xFE, 0xFF], "UTF-32 BE" }, + { [0xFF, 0xFE, 0x00], "UTF-16 LE" }, + { [0xFF, 0xFE, 0x00, 0x01], "UTF-16 LE" }, + { [.. Encoding.Unicode.GetBytes("# Project\r\n")], UnmarkedWideEncodingName }, + { [.. Encoding.BigEndianUnicode.GetBytes("# Project\n")], UnmarkedWideEncodingName }, + { [.. new UTF32Encoding(false, false).GetBytes("# Project\n")], UnmarkedWideEncodingName }, + { [(byte)'a', (byte)'\n', 0x00, (byte)'b', (byte)'\n'], UnmarkedWideEncodingName }, + { [0x00, 0x00, 0xFE], UnmarkedWideEncodingName }, + { [0x00, 0xFE, 0xFF], UnmarkedWideEncodingName }, + { [0xEF, 0xBB, 0xBF, 0x00], UnmarkedWideEncodingName }, + { [0x00], UnmarkedWideEncodingName } + }; + + public static TheoryData AcceptedPrefixFiles { get; } = new() + { + new byte[] { 0xFF }, + new byte[] { 0xFE }, + new byte[] { 0xFF, 0xC3, (byte)'\n' }, + new byte[] { 0xFE, 0xFE, 0xFF }, + new byte[] { 0xEF, 0xBB, 0xBF, 0xFF, 0xFE, (byte)'\n' }, + Encoding.UTF8.GetBytes("# Grüße 漢字\n") + }; + + [Fact] + public async Task ReadAsync_FileMissing_ReturnsNull() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + + // Act + var content = await TextFileContent.ReadAsync(Path.Combine(tempDirectory.RootPath, FileName), + TestContext.Current.CancellationToken); + + // Assert + content + .Should().BeNull(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("relative/notes.txt")] + public async Task ReadAsync_PathNotAbsolute_ThrowsArgumentException(string filePath) + { + // Act + var act = () => TextFileContent.ReadAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReadAsync_CanceledToken_ThrowsOperationCanceledException() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile("text\n", FileName); + + // Act + var act = () => TextFileContent.ReadAsync(filePath, new CancellationToken(true)); + + // Assert + await act.Should().ThrowAsync(); + } + + [Theory] + [MemberData(nameof(UnsupportedEncodingFiles))] + public async Task ReadAsync_FileInUtf16OrUtf32_ThrowsUnsupportedFileEncodingException(byte[] bytes, + string expectedEncodingName) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFileBytes(bytes, FileName); + + // Act + var act = () => TextFileContent.ReadAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + var exception = (await act.Should().ThrowAsync()).Which; + + exception.FilePath + .Should().Be(filePath); + + exception.EncodingName + .Should().Be(expectedEncodingName); + } + + [Theory] + [MemberData(nameof(AcceptedPrefixFiles))] + public async Task ReadAsync_FileWithoutUtf16OrUtf32BomOrNulByte_ReturnsContent(byte[] bytes) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFileBytes(bytes, FileName); + + // Act + var content = await TextFileContent.ReadAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + content + .Should().NotBeNull(); + + content.HasBytes(bytes) + .Should().BeTrue(); + } + + [Fact] + public async Task ReadAsync_PathIsDirectory_ThrowsPathNotAFileException() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var directoryPath = tempDirectory.CreateDirectory(FileName); + + // Act + var act = () => TextFileContent.ReadAsync(directoryPath, TestContext.Current.CancellationToken); + + // Assert + (await act.Should().ThrowAsync()) + .Which.Path.Should().Be(directoryPath); + } + + [Fact] + public async Task Lines_LfFile_ReturnsLinesWithoutTerminators() + { + // Arrange + var content = await ReadAsync("a\n\n b \n"); + + // Act + var lines = content.Lines; + + // Assert + lines.Select(line => line.Text) + .Should().Equal("a", "", " b "); + + lines.Select(line => (line.Start, line.ContentEnd)) + .Should().Equal((0, 1), (2, 2), (3, 6)); + } + + [Fact] + public async Task Lines_CrLfFileWithoutFinalNewline_ExcludesCrAndKeepsLastLine() + { + // Arrange + var content = await ReadAsync("a\r\nb"); + + // Act + var lines = content.Lines; + + // Assert + lines.Select(line => line.Text) + .Should().Equal("a", "b"); + + lines.Select(line => (line.Start, line.ContentEnd)) + .Should().Equal((0, 1), (3, 4)); + } + + [Fact] + public async Task Lines_FileWithBom_FirstLineStartsAfterBom() + { + // Arrange + var content = await ReadAsync([.. TestText.Utf8Bom, .. "a\n"u8]); + + // Act + var lines = content.Lines; + + // Assert + lines + .Should().Equal(new TextFileLine("a", 3, 4)); + } + + [Theory] + [InlineData(false, 0)] + [InlineData(true, 3)] + public async Task Lines_FileStartsWithNewline_FirstLineIsEmpty(bool withBom, int expectedStart) + { + // Arrange + byte[] prefix = withBom ? TestText.Utf8Bom : []; + var content = await ReadAsync([.. prefix, .. "\nb"u8]); + + // Act + var lines = content.Lines; + + // Assert + lines + .Should().Equal(new TextFileLine("", expectedStart, expectedStart), + new TextFileLine("b", expectedStart + 1, expectedStart + 2)); + } + + [Fact] + public async Task Lines_BlankCrLfLine_IsEmptyWithoutCr() + { + // Arrange + var content = await ReadAsync("a\r\n\r\nb"); + + // Act + var lines = content.Lines; + + // Assert + lines + .Should().Equal(new TextFileLine("a", 0, 1), new TextFileLine("", 3, 3), new TextFileLine("b", 5, 6)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Lines_EmptyFile_IsEmpty(bool withBom) + { + // Arrange + var content = await ReadAsync(withBom ? TestText.Utf8Bom : []); + + // Act + var lines = content.Lines; + + // Assert + lines + .Should().BeEmpty(); + } + + [Fact] + public async Task AppendBlock_InvalidUtf8Content_KeepsExistingBytes() + { + // Arrange + byte[] original = [0xFF, 0xC3, (byte)'\n']; + var content = await ReadAsync(original); + + // Act + var bytes = content.AppendBlock("x"); + + // Assert + bytes + .Should().Equal([.. original, .. "\nx\n"u8]); + } + + [Fact] + public async Task HasBytes_EmptyFileAndEmptyBytes_ReturnsTrue() + { + // Arrange + var content = await ReadAsync([]); + + // Act + var hasBytes = content.HasBytes([]); + + // Assert + hasBytes + .Should().BeTrue(); + } + + [Theory] + [InlineData("a\nb\n", "\n")] + [InlineData("a\r\nb\r\n", "\r\n")] + [InlineData("a\nb\r\n", "\r\n")] + [InlineData("", "\n")] + public async Task NewLine_FileContent_IsCrLfOnlyWhenFileContainsCrLf(string text, string expectedNewLine) + { + // Arrange + var content = await ReadAsync(text); + + // Act + var newLine = content.NewLine; + + // Assert + newLine + .Should().Be(expectedNewLine); + } + + [Fact] + public void CreateNew_Block_ReturnsUtf8WithoutBomAndFinalLf() + { + // Act + var bytes = TextFileContent.CreateNew("a\nb"); + + // Assert + bytes + .Should().Equal("a\nb\n"u8.ToArray()); + } + + [Theory] + [InlineData("", "x\ny\n")] + [InlineData("a\n", "a\n\nx\ny\n")] + [InlineData("a", "a\n\nx\ny\n")] + [InlineData("a\r\n", "a\r\n\r\nx\r\ny\r\n")] + [InlineData("a\r\nb", "a\r\nb\r\n\r\nx\r\ny\r\n")] + [InlineData("a\n\n", "a\n\nx\ny\n")] + [InlineData("a\r\n\r\n", "a\r\n\r\nx\r\ny\r\n")] + [InlineData("a\n\n\n", "a\n\n\nx\ny\n")] + [InlineData("\n", "\nx\ny\n")] + [InlineData("a\n \n", "a\n \n\nx\ny\n")] + public async Task AppendBlock_FileContent_AppendsBlockAfterSeparator(string text, string expectedText) + { + // Arrange + var content = await ReadAsync(text); + + // Act + var bytes = content.AppendBlock("x\ny"); + + // Assert + Encoding.UTF8.GetString(bytes) + .Should().Be(expectedText); + } + + [Fact] + public async Task AppendBlock_FileWithOnlyBom_KeepsBomWithoutBlankLine() + { + // Arrange + var content = await ReadAsync(TestText.Utf8Bom); + + // Act + var bytes = content.AppendBlock("x"); + + // Assert + bytes + .Should().Equal([.. TestText.Utf8Bom, .. "x\n"u8]); + } + + [Theory] + [InlineData("\n", "\nx\n")] + [InlineData("a\n\n", "a\n\nx\n")] + [InlineData("a\n", "a\n\nx\n")] + public async Task AppendBlock_FileWithBom_KeepsBomAndAddsBlankLineOnlyIfMissing(string text, string expectedText) + { + // Arrange + var content = await ReadAsync([.. TestText.Utf8Bom, .. Encoding.UTF8.GetBytes(text)]); + + // Act + var bytes = content.AppendBlock("x"); + + // Assert + bytes + .Should().Equal([.. TestText.Utf8Bom, .. Encoding.UTF8.GetBytes(expectedText)]); + } + + [Fact] + public async Task ReplaceLines_CrLfFile_ReplacesLinesAndKeepsTerminatorOfLastLine() + { + // Arrange + var content = await ReadAsync("a\r\nb\r\nc\r\nd\r\n"); + + // Act + var bytes = content.ReplaceLines(content.Lines[1], content.Lines[2], "x\ny"); + + // Assert + Encoding.UTF8.GetString(bytes) + .Should().Be("a\r\nx\r\ny\r\nd\r\n"); + } + + [Fact] + public async Task ReplaceLines_LastLineWithoutTerminator_EndsWithoutTerminator() + { + // Arrange + var content = await ReadAsync("a\nb"); + + // Act + var bytes = content.ReplaceLines(content.Lines[1], content.Lines[1], "x"); + + // Assert + Encoding.UTF8.GetString(bytes) + .Should().Be("a\nx"); + } + + [Fact] + public async Task ReplaceLines_InvalidUtf8OutsideReplacedLines_KeepsBytes() + { + // Arrange + byte[] original = [0xFF, 0xC3, (byte)'\n', (byte)'b', (byte)'\n', 0xC3, (byte)'\n']; + var content = await ReadAsync(original); + + // Act + var bytes = content.ReplaceLines(content.Lines[1], content.Lines[1], "x"); + + // Assert + bytes + .Should().Equal(0xFF, 0xC3, (byte)'\n', (byte)'x', (byte)'\n', 0xC3, (byte)'\n'); + } + + [Theory] + [InlineData("a\n", true)] + [InlineData("a", false)] + [InlineData("a\r\n", false)] + public async Task HasBytes_Bytes_ReturnsWhetherFileConsistsOfThem(string text, bool expected) + { + // Arrange + var content = await ReadAsync("a\n"); + + // Act + var hasBytes = content.HasBytes(Encoding.UTF8.GetBytes(text)); + + // Assert + hasBytes + .Should().Be(expected); + } + + private static Task ReadAsync(string text) + { + return ReadAsync(Encoding.UTF8.GetBytes(text)); + } + + private static async Task ReadAsync(byte[] bytes) + { + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFileBytes(bytes, FileName); + + var content = await TextFileContent.ReadAsync(filePath, TestContext.Current.CancellationToken); + + return content ?? throw new InvalidOperationException("File content is null"); + } +} diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Files/UnsupportedFileEncodingExceptionTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Files/UnsupportedFileEncodingExceptionTests.cs new file mode 100644 index 0000000..06f7af7 --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Files/UnsupportedFileEncodingExceptionTests.cs @@ -0,0 +1,24 @@ +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Files; + +namespace CreativeCoders.ShadeKit.Core.Tests.Files; + +public class UnsupportedFileEncodingExceptionTests +{ + [Fact] + public void Constructor_FilePathAndEncodingName_MessageNamesFileAndEncoding() + { + // Act + var exception = new UnsupportedFileEncodingException("/repo/.gitignore", "UTF-16 LE"); + + // Assert + exception.Message + .Should().Be("The file '/repo/.gitignore' is encoded as UTF-16 LE; only UTF-8 files can be changed."); + + exception.FilePath + .Should().Be("/repo/.gitignore"); + + exception.EncodingName + .Should().Be("UTF-16 LE"); + } +} diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Formats/VersionedJsonSerializerTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Formats/VersionedJsonSerializerTests.cs index 1651953..1078b67 100644 --- a/tests/CreativeCoders.ShadeKit.Core.Tests/Formats/VersionedJsonSerializerTests.cs +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Formats/VersionedJsonSerializerTests.cs @@ -264,7 +264,10 @@ public void Deserialize_Version1FixtureWithVersion2Step_MigratesInMemoryAndWarns result.Document.MinEngineVersion .Should().Be("0.2.0"); - result.Document.ExtensionData!["futureField"].GetString() + result.Document.ExtensionData + .Should().NotBeNull(); + + result.Document.ExtensionData["futureField"].GetString() .Should().Be("kept"); result.Document.ExtensionData @@ -319,7 +322,10 @@ public void Deserialize_Version2FixtureWithThreeVersions_AppliesOnlyRemainingSte result.Document.Title .Should().Be("Login"); - result.Document.ExtensionData!["name"].GetString() + result.Document.ExtensionData + .Should().NotBeNull(); + + result.Document.ExtensionData["name"].GetString() .Should().Be("not renamed"); result.Document.Mode @@ -366,7 +372,10 @@ public void Deserialize_CurrentVersionWithMigrationAllowed_ReturnsDocumentWithou result.Warnings .Should().BeEmpty(); - result.Document.ExtensionData!["name"].GetString() + result.Document.ExtensionData + .Should().NotBeNull(); + + result.Document.ExtensionData["name"].GetString() .Should().Be("not renamed"); } @@ -550,12 +559,25 @@ public void Serialize_DocumentWithOtherVersionValues_StampsCurrentVersionAndItsM var json = serializer.Serialize(document, SampleFormat.Id); // Assert - var root = JsonNode.Parse(json)!; + var root = JsonNode.Parse(json); + + root + .Should().NotBeNull(); + + var schemaVersionNode = root["schemaVersion"]; - root["schemaVersion"]!.GetValue() + schemaVersionNode + .Should().NotBeNull(); + + schemaVersionNode.GetValue() .Should().Be(2); - root["minEngineVersion"]!.GetValue() + var minEngineVersionNode = root["minEngineVersion"]; + + minEngineVersionNode + .Should().NotBeNull(); + + minEngineVersionNode.GetValue() .Should().Be("0.2.0"); } @@ -613,7 +635,12 @@ public void Serialize_DocumentWithUnknownFields_WritesVersionFieldsFirstAndUnkno var json = serializer.Serialize(document, SampleFormat.Id); // Assert - JsonNode.Parse(json)!.AsObject().Select(x => x.Key) + var root = JsonNode.Parse(json); + + root + .Should().NotBeNull(); + + root.AsObject().Select(x => x.Key) .Should().Equal("schemaVersion", "minEngineVersion", "title", "mode", "zeta", "alpha"); } @@ -656,7 +683,12 @@ public void Serialize_NullKnownFields_OmitsThem() var json = serializer.Serialize(document, SampleFormat.Id); // Assert - JsonNode.Parse(json)!.AsObject().Select(x => x.Key) + var root = JsonNode.Parse(json); + + root + .Should().NotBeNull(); + + root.AsObject().Select(x => x.Key) .Should().Equal("schemaVersion", "minEngineVersion", "mode"); } @@ -671,7 +703,17 @@ public void Serialize_DerivedDocumentTypedAsBase_WritesDerivedFields() var json = serializer.Serialize(document, SampleFormat.Id); // Assert - JsonNode.Parse(json)!["title"]!.GetValue() + var root = JsonNode.Parse(json); + + root + .Should().NotBeNull(); + + var titleNode = root["title"]; + + titleNode + .Should().NotBeNull(); + + titleNode.GetValue() .Should().Be("Login"); } diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Gates/GatesConfigTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Gates/GatesConfigTests.cs new file mode 100644 index 0000000..e233487 --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Gates/GatesConfigTests.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Nodes; +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Formats; +using CreativeCoders.ShadeKit.Core.Gates; + +namespace CreativeCoders.ShadeKit.Core.Tests.Gates; + +public class GatesConfigTests +{ + private const string FilePath = ".shadekit/gates.json"; + + [Fact] + public void Serialize_NewGatesConfig_WritesOnlySchemaVersionAndMinEngineVersion() + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var json = serializer.Serialize(new GatesConfig(), FormatIds.Gates); + + // Assert + json + .Should().Be( + """ + { + "schemaVersion": 1, + "minEngineVersion": "0.1.0" + } + + """.ReplaceLineEndings("\n")); + } + + [Fact] + [Trait("Requirement", "FP01-AC3")] + public void SerializeAfterDeserialize_GatesWithFieldsOfLaterPackages_KeepsThemUnchanged() + { + // Arrange + const string original = + """ + { + "schemaVersion": 1, + "minEngineVersion": "0.1.0", + "gates": { "plan": { "approval": false, "checklist": "checks/definition-of-ready.md" } }, + "threshold": 80.0 + } + """; + + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var gates = serializer.Deserialize(original, FormatIds.Gates, FilePath, false).Document; + var json = serializer.Serialize(gates, FormatIds.Gates); + + // Assert + JsonNode.DeepEquals(JsonNode.Parse(json), JsonNode.Parse(original)) + .Should().BeTrue($"the written gates file must keep every field, but was:{Environment.NewLine}{json}"); + + json.Should().Contain("\"threshold\": 80.0"); + } +} diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Init/GitIgnorePlannerTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Init/GitIgnorePlannerTests.cs new file mode 100644 index 0000000..db30b16 --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Init/GitIgnorePlannerTests.cs @@ -0,0 +1,508 @@ +using System.Text; +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Files; +using CreativeCoders.ShadeKit.Core.Init; +using CreativeCoders.ShadeKit.Tests.Shared; + +namespace CreativeCoders.ShadeKit.Core.Tests.Init; + +public class GitIgnorePlannerTests +{ + private const string FileName = ".gitignore"; + + private const string Comment = "# SHADE kit: local-only files"; + + private const string LocalEntry = ".shadekit/local.json"; + + private const string CurrentFeatureEntry = ".shadekit/features/current-feature.json"; + + [Fact] + [Trait("Requirement", "FP02-AC4")] + public async Task PlanAsync_FileMissing_CreatesFileWithCommentAndBothEntries() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = Path.Combine(tempDirectory.RootPath, FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Created); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(Encoding.UTF8.GetBytes($"{Comment}\n{LocalEntry}\n{CurrentFeatureEntry}\n")); + } + + [Theory] + [Trait("Requirement", "FP02-AC4")] + [InlineData("bin/\n.shadekit/local.json\n.shadekit/features/current-feature.json\n")] + [InlineData("/.shadekit/local.json\n/.shadekit/features/current-feature.json\n")] + [InlineData(".shadekit/local.json \n/.shadekit/features/current-feature.json \n")] + [InlineData(".shadekit/features/current-feature.json\r\n.shadekit/local.json")] + [InlineData(".shadekit/local.json\r\n/.shadekit/features/current-feature.json\r\n")] + [InlineData(".shadekit/local.json \r\n.shadekit/features/current-feature.json \r\n")] + [InlineData(".shadekit/features/current-feature.json\n.shadekit/local.json ")] + public async Task PlanAsync_BothEntriesPresent_ReturnsNullAndLeavesFileUnchanged(string text) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var original = Encoding.UTF8.GetBytes(text); + var filePath = tempDirectory.WriteFileBytes(original, FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().BeNull(); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(original); + } + + [Fact] + public async Task PlanAsync_OneEntryMissing_AppendsOnlyMissingEntryUnderComment() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile("bin/\n/.shadekit/local.json\n", FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Appended); + + tempDirectory.ReadFileText(FileName) + .Should().Be($"bin/\n/.shadekit/local.json\n\n{Comment}\n{CurrentFeatureEntry}\n"); + } + + [Fact] + public async Task PlanAsync_NoEntryPresent_AppendsBothEntriesAfterBlankLine() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile("bin/\nobj/\n", FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Appended); + + tempDirectory.ReadFileText(FileName) + .Should().Be($"bin/\nobj/\n\n{Comment}\n{LocalEntry}\n{CurrentFeatureEntry}\n"); + } + + [Theory] + [InlineData("# .shadekit/local.json\n")] + [InlineData(".shadekit/local.json.bak\n")] + [InlineData("!.shadekit/local.json\n")] + [InlineData(".shadekit/\n")] + [InlineData(".shadekit/local.json\\ \n")] + [InlineData(".ShadeKit/Local.json\n")] + public async Task PlanAsync_LineSimilarToEntry_DoesNotCountAsEntry(string text) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile(text, FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Appended); + + tempDirectory.ReadFileText(FileName) + .Should().Be($"{text}\n{Comment}\n{LocalEntry}\n{CurrentFeatureEntry}\n"); + } + + [Theory] + [InlineData(" .shadekit/local.json\n.shadekit/features/current-feature.json\n")] + [InlineData("\t/.shadekit/local.json\n.shadekit/features/current-feature.json\n")] + public async Task PlanAsync_EntryWithLeadingWhitespace_DoesNotCountAndAppendsEntry(string text) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile(text, FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Appended); + + tempDirectory.ReadFileText(FileName) + .Should().Be($"{text}\n{Comment}\n{LocalEntry}\n"); + } + + [Theory] + [InlineData(".shadekit/local.json\t\n.shadekit/features/current-feature.json\n", "\n")] + [InlineData("/.shadekit/local.json \t\n.shadekit/features/current-feature.json\n", "\n")] + [InlineData(".shadekit/local.json\t\r\n.shadekit/features/current-feature.json\r\n", "\r\n")] + public async Task PlanAsync_EntryWithTrailingTab_DoesNotCountAndAppendsEntry(string text, string newLine) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile(text, FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Appended); + + tempDirectory.ReadFileText(FileName) + .Should().Be($"{text}{newLine}{Comment}{newLine}{LocalEntry}{newLine}"); + } + + [Fact] + public async Task PlanAsync_NoTrailingNewline_AddsNewlineBeforeBlankLine() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile("bin/", FileName); + + // Act + await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileText(FileName) + .Should().Be($"bin/\n\n{Comment}\n{LocalEntry}\n{CurrentFeatureEntry}\n"); + } + + [Fact] + public async Task PlanAsync_EmptyFile_AppendsWithoutBlankLine() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile(string.Empty, FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Appended); + + tempDirectory.ReadFileText(FileName) + .Should().Be($"{Comment}\n{LocalEntry}\n{CurrentFeatureEntry}\n"); + } + + [Fact] + public async Task PlanAsync_CrLfFile_AppendsWithCrLf() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile("bin/\r\nobj/", FileName); + + // Act + await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileText(FileName) + .Should().Be($"bin/\r\nobj/\r\n\r\n{Comment}\r\n{LocalEntry}\r\n{CurrentFeatureEntry}\r\n"); + } + + [Fact] + public async Task PlanAsync_FileWithBom_KeepsBom() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFileBytes([.. TestText.Utf8Bom, .. "bin/\n"u8], FileName); + + // Act + await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileBytes(FileName) + .Should().Equal([ + .. TestText.Utf8Bom, + .. Encoding.UTF8.GetBytes($"bin/\n\n{Comment}\n{LocalEntry}\n{CurrentFeatureEntry}\n") + ]); + } + + [Fact] + [Trait("Requirement", "FP02-AC4")] + public async Task PlanAsync_CalledTwice_ContainsEachEntryExactlyOnce() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile("bin/\n", FileName); + await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + var afterFirstCall = tempDirectory.ReadFileBytes(FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().BeNull(); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(afterFirstCall); + + var lines = tempDirectory.ReadFileText(FileName).Split('\n'); + + lines.Count(line => line == LocalEntry) + .Should().Be(1); + + lines.Count(line => line == CurrentFeatureEntry) + .Should().Be(1); + } + + [Fact] + [Trait("Requirement", "FP02-AC4")] + public async Task PlanAsync_FileWrittenFromPlan_SecondCallReturnsNullAndLeavesFileUnchanged() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = Path.Combine(tempDirectory.RootPath, FileName); + await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + var afterFirstCall = tempDirectory.ReadFileBytes(FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().BeNull(); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(afterFirstCall); + } + + [Fact] + public async Task PlanAsync_BomFileWithEntryOnFirstLine_CountsEntryAsPresent() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + byte[] original = [.. TestText.Utf8Bom, .. Encoding.UTF8.GetBytes($"{LocalEntry}\n")]; + var filePath = tempDirectory.WriteFileBytes(original, FileName); + + // Act + await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileBytes(FileName) + .Should().Equal([.. original, .. Encoding.UTF8.GetBytes($"\n{Comment}\n{CurrentFeatureEntry}\n")]); + } + + [Fact] + public async Task PlanAsync_FirstEntryMissing_AppendsOnlyFirstEntry() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile($"{CurrentFeatureEntry}\n", FileName); + + // Act + await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileText(FileName) + .Should().Be($"{CurrentFeatureEntry}\n\n{Comment}\n{LocalEntry}\n"); + } + + [Fact] + public async Task PlanAsync_CommentPresentButEntryRemoved_AppendsEntryUnderNewComment() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile($"{Comment}\n{LocalEntry}\n", FileName); + + // Act + await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileText(FileName) + .Should().Be($"{Comment}\n{LocalEntry}\n\n{Comment}\n{CurrentFeatureEntry}\n"); + } + + [Fact] + public async Task PlanAsync_FileWithOnlyNewline_AppendsWithoutAnotherBlankLine() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile("\n", FileName); + + // Act + await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileText(FileName) + .Should().Be($"\n{Comment}\n{LocalEntry}\n{CurrentFeatureEntry}\n"); + } + + [Theory] + [InlineData("bin/\n\n", "\n")] + [InlineData("bin/\r\n\r\n", "\r\n")] + public async Task PlanAsync_FileEndsWithBlankLine_AppendsWithoutAnotherBlankLine(string text, + string newLine) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile(text, FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Appended); + + tempDirectory.ReadFileText(FileName) + .Should().Be($"{text}{Comment}{newLine}{LocalEntry}{newLine}{CurrentFeatureEntry}{newLine}"); + } + + [Fact] + public async Task PlanAsync_Utf16File_ThrowsUnsupportedFileEncodingExceptionAndLeavesFileUnchanged() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + byte[] original = [0xFF, 0xFE, .. Encoding.Unicode.GetBytes("bin/\r\n")]; + var filePath = tempDirectory.WriteFileBytes(original, FileName); + + // Act + var act = () => new GitIgnorePlanner().PlanAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + var exception = (await act.Should().ThrowAsync()).Which; + + exception.FilePath + .Should().Be(filePath); + + exception.EncodingName + .Should().Be("UTF-16 LE"); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(original); + } + + [Fact] + public async Task PlanAsync_PathIsDirectory_ThrowsPathNotAFileException() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var directoryPath = tempDirectory.CreateDirectory(FileName); + + // Act + var act = () => new GitIgnorePlanner().PlanAsync(directoryPath, TestContext.Current.CancellationToken); + + // Assert + (await act.Should().ThrowAsync()) + .Which.Path.Should().Be(directoryPath); + + Directory.Exists(directoryPath) + .Should().BeTrue(); + } + + [Theory] + [InlineData("bin/\n")] + [InlineData("")] + public async Task PlanAsync_Utf8File_DoesNotThrowAndLeavesFileUnchanged(string text) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var original = Encoding.UTF8.GetBytes(text); + var filePath = tempDirectory.WriteFileBytes(original, FileName); + + // Act + var act = () => new GitIgnorePlanner().PlanAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + await act.Should().NotThrowAsync(); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(original); + } + + [Fact] + public async Task PlanAsync_FileMissing_DoesNotThrowAndCreatesNoFile() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = Path.Combine(tempDirectory.RootPath, FileName); + + // Act + var act = () => new GitIgnorePlanner().PlanAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + await act.Should().NotThrowAsync(); + + File.Exists(filePath) + .Should().BeFalse(); + } + + [Fact] + public async Task PlanAsync_CanceledTokenWithExistingFile_ThrowsAndLeavesFileUnchanged() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var original = "bin/\n"u8.ToArray(); + var filePath = tempDirectory.WriteFileBytes(original, FileName); + + // Act + var act = () => new GitIgnorePlanner().PlanAsync(filePath, new CancellationToken(true)); + + // Assert + await act.Should().ThrowAsync(); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(original); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(".gitignore")] + public async Task PlanAsync_PathNotAbsolute_ThrowsArgumentException(string? filePath) + { + // Act + var act = () => new GitIgnorePlanner().PlanAsync(filePath!, TestContext.Current.CancellationToken); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PlanAsync_CanceledToken_ThrowsAndCreatesNoFile() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = Path.Combine(tempDirectory.RootPath, FileName); + + // Act + var act = () => new GitIgnorePlanner().PlanAsync(filePath, new CancellationToken(true)); + + // Assert + await act.Should().ThrowAsync(); + + File.Exists(filePath) + .Should().BeFalse(); + } + + /// + /// Plans the change of a .gitignore file and writes it, as the initialisation does. + /// + /// The action of the planned change, or if the file needs no change. + private static async Task PlanAndWriteAsync(string filePath, + CancellationToken cancellationToken) + { + var change = await new GitIgnorePlanner().PlanAsync(filePath, cancellationToken); + + if (change is not null) + { + await File.WriteAllBytesAsync(filePath, change.Content, cancellationToken); + } + + return change?.Action; + } +} diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Init/InstructionSectionPlannerTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Init/InstructionSectionPlannerTests.cs new file mode 100644 index 0000000..b33fd38 --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Init/InstructionSectionPlannerTests.cs @@ -0,0 +1,629 @@ +using System.Text; +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Files; +using CreativeCoders.ShadeKit.Core.Init; +using CreativeCoders.ShadeKit.Tests.Shared; + +namespace CreativeCoders.ShadeKit.Core.Tests.Init; + +public class InstructionSectionPlannerTests +{ + private const string FileName = "CLAUDE.md"; + + private const string Begin = ""; + + private const string End = ""; + + private const string ExpectedSection = ManagedInstructionSection.Text; + + public static TheoryData InvalidMarkerFiles { get; } = new() + { + { $"# Title\n{Begin}\nold\n", "The begin marker in line 2 has no end marker." }, + { $"# Title\n{End}\n", "The end marker in line 2 has no begin marker." }, + { $"{End}\ntext\n{Begin}\n", "The end marker in line 1 comes before the begin marker in line 3." }, + { $"{Begin}\n{Begin}\n{End}\n", "The file contains more than one begin marker (lines 1, 2)." }, + { $"{Begin}\n{End}\n{End}\n", "The file contains more than one end marker (lines 2, 3)." }, + { $"{Begin}\n{End}\n{Begin}\n{End}\n", "The file contains more than one begin marker (lines 1, 3)." }, + { $" {Begin}\r\n\t{Begin} again\r\n{End}\r\n", "The file contains more than one begin marker (lines 1, 2)." }, + { $"{End}\n{End}\n", "The file contains more than one end marker (lines 1, 2)." }, + { $"{Begin}\n{Begin}\n", "The file contains more than one begin marker (lines 1, 2)." }, + { $" ```\n{Begin}\n{End}\n ```\n{Begin}\n{End}\n", "The file contains more than one begin marker (lines 2, 5)." }, + { $"```\n{Begin}\n```\n{End}\n", "The end marker in line 4 has no begin marker." }, + { $"{Begin}\n```\n{End}\n```\n", "The begin marker in line 1 has no end marker." }, + { $"\n", + "\n" + }; + + // Files with one section outside any fenced code block, and the expected file after the update. + public static TheoryData FilesWithSection { get; } = new() + { + { $"```\n{Begin}\nexample\n{End}\n```\n{Begin}\nold\n{End}\n", $"```\n{Begin}\nexample\n{End}\n```\n{ExpectedSection}\n" }, + { $"\n{Begin}\nold\n{End}\n", $"\n{ExpectedSection}\n" }, + { $"\nold\n{End}\n", $"{ExpectedSection}\n" }, + { $"\nold\n{End}\n", $"{ExpectedSection}\n" }, + { $" ```\n{Begin}\nold\n{End}\n", $" ```\n{ExpectedSection}\n" }, + { $"```a`b\n{Begin}\nold\n{End}\n", $"```a`b\n{ExpectedSection}\n" }, + { $"``\n{Begin}\nold\n{End}\n", $"``\n{ExpectedSection}\n" }, + { $"```\ncode\n```\n{Begin}\nold\n{End}\n", $"```\ncode\n```\n{ExpectedSection}\n" }, + { $"{Begin}\nold\n```\n{End}\n```\n{End}\nrest\n", $"{ExpectedSection}\nrest\n" } + }; + + public static TheoryData UnsupportedEncodingFiles { get; } = new() + { + { [0xFF, 0xFE, .. Encoding.Unicode.GetBytes("# Project\r\n")], "UTF-16 LE" }, + { [0xFE, 0xFF, .. Encoding.BigEndianUnicode.GetBytes("# Project\n")], "UTF-16 BE" }, + { [0xFF, 0xFE, 0x00, 0x00, .. Encoding.UTF32.GetBytes("# Project\n")], "UTF-32 LE" }, + { [0x00, 0x00, 0xFE, 0xFF, 0x00, 0x00, 0x00, (byte)'#'], "UTF-32 BE" } + }; + + [Fact] + public async Task PlanAsync_FileWrittenFromPlan_SecondCallReturnsNullAndLeavesFileUnchanged() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = Path.Combine(tempDirectory.RootPath, FileName); + await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + var afterFirstCall = tempDirectory.ReadFileBytes(FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().BeNull(); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(afterFirstCall); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PlanAsync_SectionAtStartOfFile_ReplacesSectionAndKeepsBom(bool withBom) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + byte[] prefix = withBom ? TestText.Utf8Bom : []; + var filePath = tempDirectory.WriteFileBytes([.. prefix, .. Encoding.UTF8.GetBytes($"{Begin}\nold\n{End}\nrest\n")], + FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Updated); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal([.. prefix, .. Encoding.UTF8.GetBytes($"{ExpectedSection}\nrest\n")]); + } + + [Theory] + [InlineData(true, "\n")] + [InlineData(false, "")] + public async Task PlanAsync_IdenticalSectionWithBomOrWithoutTerminator_ReturnsNullAndLeavesFileUnchanged( + bool withBom, string terminator) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + byte[] prefix = withBom ? TestText.Utf8Bom : []; + byte[] original = [.. prefix, .. Encoding.UTF8.GetBytes($"intro\n{ExpectedSection}{terminator}")]; + var filePath = tempDirectory.WriteFileBytes(original, FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().BeNull(); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(original); + } + + [Fact] + public async Task PlanAsync_LfSectionInFileWithCrLf_UpdatesSectionToCrLfOnce() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile($"intro\r\n{ExpectedSection}\n", FileName); + + // Act + var firstAction = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + var secondAction = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + firstAction + .Should().Be(InitializedFileAction.Updated); + + secondAction + .Should().BeNull(); + + tempDirectory.ReadFileText(FileName) + .Should().Be($"intro\r\n{ToCrLf(ExpectedSection)}\n"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PlanAsync_EmptyFile_AppendsOnlyTheSection(bool withBom) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + byte[] prefix = withBom ? TestText.Utf8Bom : []; + var filePath = tempDirectory.WriteFileBytes(prefix, FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Appended); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal([.. prefix, .. Encoding.UTF8.GetBytes(ExpectedSection + "\n")]); + } + + [Fact] + public async Task PlanAsync_CanceledTokenWithExistingFile_ThrowsAndLeavesFileUnchanged() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var original = "# Project\n"u8.ToArray(); + var filePath = tempDirectory.WriteFileBytes(original, FileName); + + // Act + var act = () => new InstructionSectionPlanner().PlanAsync(filePath, new CancellationToken(true)); + + // Assert + await act.Should().ThrowAsync(); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(original); + } + + [Fact] + [Trait("Requirement", "FP02-AC5")] + public async Task PlanAsync_FileMissing_CreatesFileWithOnlyTheSection() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = Path.Combine(tempDirectory.RootPath, FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Created); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(Encoding.UTF8.GetBytes(ExpectedSection + "\n")); + } + + [Fact] + [Trait("Requirement", "FP02-AC5")] + public async Task PlanAsync_FileWithoutMarkers_AppendsSectionAndKeepsExistingContentUnchanged() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var original = Encoding.UTF8.GetBytes("# Project\n\nRules für Ümlaute.\n"); + var filePath = tempDirectory.WriteFileBytes(original, FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Appended); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal([.. original, .. Encoding.UTF8.GetBytes("\n" + ExpectedSection + "\n")]); + } + + [Fact] + public async Task PlanAsync_FileWithoutTrailingNewline_AddsNewlineBeforeBlankLine() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile("# Project", FileName); + + // Act + await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileText(FileName) + .Should().Be("# Project\n\n" + ExpectedSection + "\n"); + } + + [Theory] + [InlineData("\n")] + [InlineData("\r\n")] + public async Task PlanAsync_FileEndsWithBlankLine_AppendsSectionWithoutAnotherBlankLine(string newLine) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var text = $"# Project{newLine}{newLine}"; + var filePath = tempDirectory.WriteFile(text, FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Appended); + + tempDirectory.ReadFileText(FileName) + .Should().Be(text + ExpectedSection.Replace("\n", newLine, StringComparison.Ordinal) + newLine); + } + + [Theory] + [MemberData(nameof(UnsupportedEncodingFiles))] + public async Task PlanAsync_Utf16OrUtf32File_ThrowsUnsupportedFileEncodingExceptionAndLeavesFileUnchanged( + byte[] original, string expectedEncodingName) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFileBytes(original, FileName); + + // Act + var act = () => new InstructionSectionPlanner().PlanAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + var exception = (await act.Should().ThrowAsync()).Which; + + exception.FilePath + .Should().Be(filePath); + + exception.EncodingName + .Should().Be(expectedEncodingName); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(original); + } + + [Fact] + public async Task PlanAsync_PathIsDirectory_ThrowsPathNotAFileException() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var directoryPath = tempDirectory.CreateDirectory(FileName); + + // Act + var act = () => new InstructionSectionPlanner().PlanAsync(directoryPath, TestContext.Current.CancellationToken); + + // Assert + (await act.Should().ThrowAsync()) + .Which.Path.Should().Be(directoryPath); + + Directory.Exists(directoryPath) + .Should().BeTrue(); + } + + [Fact] + public async Task PlanAsync_CrLfFileWithBomWithoutMarkers_AppendsWithCrLfAndKeepsBom() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFileBytes([.. TestText.Utf8Bom, .. "# Project\r\n"u8], FileName); + + // Act + await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileBytes(FileName) + .Should().Equal([ + .. TestText.Utf8Bom, + .. Encoding.UTF8.GetBytes("# Project\r\n\r\n" + ToCrLf(ExpectedSection) + "\r\n") + ]); + } + + [Fact] + [Trait("Requirement", "FP02-AC5")] + public async Task PlanAsync_SectionWithOtherContent_ReplacesSectionAndKeepsContentOutsideUnchanged() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + const string before = "# Project\n\nIntro.\n"; + const string after = "\n## Other\n\nMore.\n"; + var filePath = tempDirectory.WriteFile($"{before}{Begin}\nold content\n{End}{after}", FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Updated); + + var text = tempDirectory.ReadFileText(FileName); + + text + .Should().Be(before + ExpectedSection + after); + + TestText.CountLinesStartingWith(text, ManagedInstructionSection.BeginMarkerPrefix) + .Should().Be(1); + + TestText.CountLinesStartingWith(text, ManagedInstructionSection.EndMarker) + .Should().Be(1); + } + + [Fact] + public async Task PlanAsync_IndentedMarkers_ReplacesWholeMarkerLines() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile($"intro\n {Begin} \nold\n\t{End}\t\noutro\n", FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Updated); + + tempDirectory.ReadFileText(FileName) + .Should().Be($"intro\n{ExpectedSection}\noutro\n"); + } + + [Fact] + public async Task PlanAsync_SectionIsLastLineWithoutTerminator_ReplacesSectionWithoutAddingTerminator() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile($"intro\n{Begin}\nold\n{End}", FileName); + + // Act + await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileText(FileName) + .Should().Be($"intro\n{ExpectedSection}"); + } + + [Fact] + public async Task PlanAsync_CrLfFileWithBomAndOtherSection_ReplacesWithCrLfAndKeepsBom() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFileBytes( + [.. TestText.Utf8Bom, .. Encoding.UTF8.GetBytes($"intro\r\n{Begin}\r\nold\r\n{End}\r\noutro\r\n")], FileName); + + // Act + await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileBytes(FileName) + .Should().Equal([ + .. TestText.Utf8Bom, + .. Encoding.UTF8.GetBytes($"intro\r\n{ToCrLf(ExpectedSection)}\r\noutro\r\n") + ]); + } + + [Theory] + [InlineData("\n")] + [InlineData("\r\n")] + public async Task PlanAsync_SectionIdentical_ReturnsNullAndLeavesFileUnchanged(string newLine) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var text = $"intro{newLine}{ExpectedSection.Replace("\n", newLine, StringComparison.Ordinal)}{newLine}" + + $"outro{newLine}"; + var original = Encoding.UTF8.GetBytes(text); + var filePath = tempDirectory.WriteFileBytes(original, FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().BeNull(); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(original); + } + + [Fact] + [Trait("Requirement", "FP02-AC5")] + public async Task PlanAsync_CalledTwice_FileContainsExactlyOneSection() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile("# Project\n", FileName); + await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + var afterFirstCall = tempDirectory.ReadFileBytes(FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().BeNull(); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(afterFirstCall); + + TestText.CountLinesStartingWith(tempDirectory.ReadFileText(FileName), ManagedInstructionSection.BeginMarkerPrefix) + .Should().Be(1); + } + + [Theory] + [MemberData(nameof(FilesWithoutMarkers))] + public async Task PlanAsync_MarkerLinesInCodeFenceOrNotMarkers_AppendsSectionAndKeepsThem(string text) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile(text, FileName); + var newLine = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Appended); + + tempDirectory.ReadFileText(FileName) + .Should().Be(text + newLine + ExpectedSection.ReplaceLineEndings(newLine) + newLine); + } + + [Theory] + [MemberData(nameof(FilesWithSection))] + public async Task PlanAsync_SectionOutsideCodeFences_ReplacesOnlyThatSection(string text, string expected) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = tempDirectory.WriteFile(text, FileName); + + // Act + var action = await PlanAndWriteAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + action + .Should().Be(InitializedFileAction.Updated); + + tempDirectory.ReadFileText(FileName) + .Should().Be(expected); + } + + [Theory] + [MemberData(nameof(InvalidMarkerFiles))] + public async Task PlanAsync_InvalidMarkers_ThrowsInvalidManagedSectionExceptionAndLeavesFileUnchanged( + string text, string expectedReason) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var original = Encoding.UTF8.GetBytes(text); + var filePath = tempDirectory.WriteFileBytes(original, FileName); + + // Act + var act = () => new InstructionSectionPlanner().PlanAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + var exception = (await act.Should().ThrowAsync()).Which; + + exception.FilePath + .Should().Be(filePath); + + exception.Reason + .Should().Be(expectedReason); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(original); + } + + [Theory] + [InlineData("# Project\n")] + [InlineData($"intro\n{Begin}\nold\n{End}\n")] + [InlineData($"intro\n {Begin}\n\t{End} ")] + [InlineData(" trailing text\n\n")] + [InlineData("\n\n")] + public async Task PlanAsync_ValidFile_DoesNotThrowAndLeavesFileUnchanged(string text) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var original = Encoding.UTF8.GetBytes(text); + var filePath = tempDirectory.WriteFileBytes(original, FileName); + + // Act + var act = () => new InstructionSectionPlanner().PlanAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + await act.Should().NotThrowAsync(); + + tempDirectory.ReadFileBytes(FileName) + .Should().Equal(original); + } + + [Fact] + public async Task PlanAsync_FileMissing_DoesNotThrowAndCreatesNoFile() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = Path.Combine(tempDirectory.RootPath, FileName); + + // Act + var act = () => new InstructionSectionPlanner().PlanAsync(filePath, TestContext.Current.CancellationToken); + + // Assert + await act.Should().NotThrowAsync(); + + File.Exists(filePath) + .Should().BeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("CLAUDE.md")] + public async Task PlanAsync_PathNotAbsolute_ThrowsArgumentException(string? filePath) + { + // Act + var act = () => new InstructionSectionPlanner().PlanAsync(filePath!, TestContext.Current.CancellationToken); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PlanAsync_CanceledToken_ThrowsAndCreatesNoFile() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var filePath = Path.Combine(tempDirectory.RootPath, FileName); + + // Act + var act = () => new InstructionSectionPlanner().PlanAsync(filePath, new CancellationToken(true)); + + // Assert + await act.Should().ThrowAsync(); + + File.Exists(filePath) + .Should().BeFalse(); + } + + private static string ToCrLf(string text) + { + return text.Replace("\n", "\r\n", StringComparison.Ordinal); + } + + /// + /// Plans the change of an instruction file and writes it, as the initialisation does. + /// + /// The action of the planned change, or if the file needs no change. + private static async Task PlanAndWriteAsync(string filePath, + CancellationToken cancellationToken) + { + var change = await new InstructionSectionPlanner().PlanAsync(filePath, cancellationToken); + + if (change is not null) + { + await File.WriteAllBytesAsync(filePath, change.Content, cancellationToken); + } + + return change?.Action; + } +} diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Init/InvalidManagedSectionExceptionTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Init/InvalidManagedSectionExceptionTests.cs new file mode 100644 index 0000000..830090a --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Init/InvalidManagedSectionExceptionTests.cs @@ -0,0 +1,25 @@ +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Init; + +namespace CreativeCoders.ShadeKit.Core.Tests.Init; + +public class InvalidManagedSectionExceptionTests +{ + [Fact] + public void Constructor_FilePathAndReason_MessageNamesFileAndReason() + { + // Act + var exception = new InvalidManagedSectionException("/repo/CLAUDE.md", "The begin marker has no end marker."); + + // Assert + exception.Message + .Should().Be("The managed SHADE kit section in '/repo/CLAUDE.md' is invalid: " + + "The begin marker has no end marker."); + + exception.FilePath + .Should().Be("/repo/CLAUDE.md"); + + exception.Reason + .Should().Be("The begin marker has no end marker."); + } +} diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Init/KitGitIgnoreEntriesTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Init/KitGitIgnoreEntriesTests.cs new file mode 100644 index 0000000..4d7eb0f --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Init/KitGitIgnoreEntriesTests.cs @@ -0,0 +1,19 @@ +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Init; + +namespace CreativeCoders.ShadeKit.Core.Tests.Init; + +public class KitGitIgnoreEntriesTests +{ + [Fact] + [Trait("Requirement", "FP02-AC4")] + public void All_Always_ContainsLocalOnlyFilesWithoutLeadingSlash() + { + // Act + var entries = KitGitIgnoreEntries.All; + + // Assert + entries + .Should().Equal(".shadekit/local.json", ".shadekit/features/current-feature.json"); + } +} diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Init/ManagedInstructionSectionTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Init/ManagedInstructionSectionTests.cs new file mode 100644 index 0000000..37b4437 --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Init/ManagedInstructionSectionTests.cs @@ -0,0 +1,54 @@ +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Init; + +namespace CreativeCoders.ShadeKit.Core.Tests.Init; + +public class ManagedInstructionSectionTests +{ + [Fact] + [Trait("Requirement", "FP02-AC5")] + public void Text_Always_IsTheManagedSectionWithMarkers() + { + // Act + var text = ManagedInstructionSection.Text; + + // Assert + text + .Should().Be( + "\n" + + "## SHADE kit\n" + + "\n" + + "This repository uses SHADE kit. Its data lives in `.shadekit/` (configuration, templates, features) " + + "and is committed with the code.\n" + + "\n" + + "- To start or continue work on a feature, use the `shade-kit-next-phase` skill.\n" + + "- Call the engine as `shade-kit --json` and act on its JSON result.\n" + + "- Change feature state only through `shade-kit` commands, never by editing `.shadekit/features/` " + + "by hand.\n" + + ""); + } + + [Fact] + public void Text_Always_StartsWithBeginMarkerPrefixAndEndsWithEndMarker() + { + // Act + var text = ManagedInstructionSection.Text; + + // Assert + text + .Should().StartWith(ManagedInstructionSection.BeginMarkerPrefix) + .And.EndWith(ManagedInstructionSection.EndMarker); + } + + [Fact] + public void Markers_Always_AreTheShadeKitHtmlComments() + { + // Act + var markers = (ManagedInstructionSection.BeginMarkerPrefix, ManagedInstructionSection.EndMarker); + + // Assert + markers + .Should().Be(("")); + } +} diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Init/RepositoryInitializerTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Init/RepositoryInitializerTests.cs new file mode 100644 index 0000000..880638a --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Init/RepositoryInitializerTests.cs @@ -0,0 +1,1174 @@ +using System.Runtime.Versioning; +using System.Text; +using System.Text.Json.Nodes; +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Config; +using CreativeCoders.ShadeKit.Core.Engine; +using CreativeCoders.ShadeKit.Core.Files; +using CreativeCoders.ShadeKit.Core.Formats; +using CreativeCoders.ShadeKit.Core.Gates; +using CreativeCoders.ShadeKit.Core.Init; +using CreativeCoders.ShadeKit.Core.Store; +using CreativeCoders.ShadeKit.Core.Templates; +using CreativeCoders.ShadeKit.Tests.Shared; +using FakeItEasy; + +namespace CreativeCoders.ShadeKit.Core.Tests.Init; + +/// +/// Integration tests for in a temporary git repository, with the real locator, +/// store factory, serializer, format registry, template reader, and writers. Only the engine version is faked. +/// +public class RepositoryInitializerTests +{ + private const string EngineVersion = "1.2.3"; + + private const string GitIgnore = ".gitignore"; + + private const string ClaudeMd = "CLAUDE.md"; + + private const string AgentsMd = "AGENTS.md"; + + private const string KitRoot = ".shadekit"; + + private static readonly FormatRegistry Registry = FormatRegistry.CreateDefault(); + + private static readonly string ExpectedSectionFile = ManagedInstructionSection.Text + "\n"; + + public static TheoryData InstructionFileNames { get; } = new() { ClaudeMd, AgentsMd }; + + public static TheoryData RepositoryFileNames { get; } = new() { GitIgnore, ClaudeMd, AgentsMd }; + + [Fact] + [Trait("Requirement", "FP02-AC1")] + public async Task InitializeAsync_GitRepositoryWithoutKitRoot_CreatesKitRootContent() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + + // Act + await CreateInitializer().InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + var kitRootPath = Path.Combine(tempDirectory.RootPath, KitRoot); + + File.Exists(Path.Combine(kitRootPath, "config.json")) + .Should().BeTrue(); + + File.Exists(Path.Combine(kitRootPath, "gates.json")) + .Should().BeTrue(); + + File.Exists(Path.Combine(kitRootPath, "templates.lock.json")) + .Should().BeTrue(); + + GetRelativeFilePaths(tempDirectory.RootPath, Path.Combine(kitRootPath, "templates")) + .Should().BeEquivalentTo(ShippedTemplates.All.Select(x => x.RepositoryPath)); + + Directory.Exists(Path.Combine(kitRootPath, "features")) + .Should().BeTrue(); + + Directory.EnumerateFileSystemEntries(Path.Combine(kitRootPath, "features")) + .Should().BeEmpty(); + } + + [Fact] + [Trait("Requirement", "FP02-AC1")] + public async Task InitializeAsync_GitRepositoryWithoutKitRoot_ReturnsRepositoryRootAndFilesInWriteOrder() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + + // Act + var result = await CreateInitializer() + .InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + result.RepositoryRootPath + .Should().Be(tempDirectory.RootPath); + + result.Files + .Should().Equal( + [ + Created(".shadekit/config.json"), + Created(".shadekit/gates.json"), + .. ShippedTemplates.All.Select(x => Created(x.RepositoryPath)), + Created(".shadekit/templates.lock.json"), + Created(".shadekit/features/"), + Created(GitIgnore), + Created(ClaudeMd), + Created(AgentsMd) + ]); + } + + [Fact] + public async Task InitializeAsync_ExistingRepositoryFiles_ReportsAppendedAndUpdatedFiles() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.WriteFile("bin/\n", GitIgnore); + tempDirectory.WriteFile("# Project\n", ClaudeMd); + tempDirectory.WriteFile("# Agents\n\nold\n\n", AgentsMd); + + // Act + var result = await CreateInitializer() + .InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + result.Files.TakeLast(3) + .Should().Equal( + new InitializedFile(GitIgnore, InitializedFileAction.Appended), + new InitializedFile(ClaudeMd, InitializedFileAction.Appended), + new InitializedFile(AgentsMd, InitializedFileAction.Updated)); + } + + [Fact] + public async Task InitializeAsync_RepositoryFilesAlreadyComplete_ListsNoRepositoryFiles() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.WriteFile(".shadekit/local.json\n.shadekit/features/current-feature.json\n", GitIgnore); + tempDirectory.WriteFile(ExpectedSectionFile, ClaudeMd); + tempDirectory.WriteFile(ExpectedSectionFile, AgentsMd); + + // Act + var result = await CreateInitializer() + .InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + result.Files.Select(x => x.Path) + .Should().NotContain([GitIgnore, ClaudeMd, AgentsMd]); + + result.Files[^1] + .Should().Be(Created(".shadekit/features/")); + } + + [Fact] + [Trait("Requirement", "FP02-AC2")] + public async Task InitializeAsync_GitRepositoryWithoutKitRoot_EveryJsonFileCarriesSchemaVersion() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + + // Act + await CreateInitializer().InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + var jsonFiles = Directory.GetFiles(Path.Combine(tempDirectory.RootPath, KitRoot), "*.json", + SearchOption.AllDirectories); + + jsonFiles.Select(x => Path.GetFileName(x)) + .Should().BeEquivalentTo("config.json", "gates.json", "templates.lock.json"); + + foreach (var jsonFile in jsonFiles) + { + var root = JsonNode.Parse(await File.ReadAllTextAsync(jsonFile, TestContext.Current.CancellationToken)); + + root + .Should().NotBeNull(jsonFile); + + var schemaVersionNode = root["schemaVersion"]; + + schemaVersionNode + .Should().NotBeNull(jsonFile); + + schemaVersionNode.GetValue() + .Should().Be(1, jsonFile); + + var minEngineVersionNode = root["minEngineVersion"]; + + minEngineVersionNode + .Should().NotBeNull(jsonFile); + + minEngineVersionNode.GetValue() + .Should().NotBeNullOrWhiteSpace(jsonFile); + } + } + + [Fact] + public async Task InitializeAsync_GitRepositoryWithoutKitRoot_WritesConfigWithInitContent() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + + // Act + await CreateInitializer().InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileBytes(KitRoot, "config.json") + .Should().Equal(Encoding.UTF8.GetBytes( + "{\n" + + " \"schemaVersion\": 1,\n" + + $" \"minEngineVersion\": \"{GetMinEngineVersion(FormatIds.Config)}\",\n" + + " \"kitVersion\": \"1.2.3\",\n" + + " \"defaultProfile\": \"feature\",\n" + + " \"outdatedFormatOnRead\": \"migrate-and-warn\",\n" + + " \"trackers\": {\n" + + " \"github.com\": \"gh\"\n" + + " },\n" + + " \"report\": \"ask\"\n" + + "}\n")); + } + + [Fact] + public async Task InitializeAsync_GitRepositoryWithoutKitRoot_WritesGatesWithVersionFieldsOnly() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + + // Act + await CreateInitializer().InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileBytes(KitRoot, "gates.json") + .Should().Equal(Encoding.UTF8.GetBytes( + "{\n" + + " \"schemaVersion\": 1,\n" + + $" \"minEngineVersion\": \"{GetMinEngineVersion(FormatIds.Gates)}\"\n" + + "}\n")); + } + + [Fact] + [Trait("Requirement", "FP02-AC3")] + public async Task InitializeAsync_GitRepositoryWithoutKitRoot_LockHasOneEntryPerTemplateWithHashOfWrittenFile() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + + // Act + await CreateInitializer().InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + var root = JsonNode.Parse(tempDirectory.ReadFileText(KitRoot, "templates.lock.json")); + + root + .Should().NotBeNull(); + + var templatesNode = root["templates"]; + + templatesNode + .Should().NotBeNull(); + + var entries = templatesNode.AsArray().Select(x => x!.AsObject()).ToArray(); + + entries.Select(x => x["path"]!.GetValue()) + .Should().Equal(ShippedTemplates.All.Select(x => x.RepositoryPath).Order(StringComparer.Ordinal)); + + foreach (var entry in entries) + { + var pathNode = entry["path"]; + + pathNode + .Should().NotBeNull(); + + var path = pathNode.GetValue(); + var writtenBytes = tempDirectory.ReadFileBytes(path.Split('/')); + + entry.Select(x => x.Key) + .Should().Equal("path", "kitVersion", "hash"); + + var kitVersionNode = entry["kitVersion"]; + + kitVersionNode + .Should().NotBeNull(); + + kitVersionNode.GetValue() + .Should().Be(EngineVersion, path); + + var hashNode = entry["hash"]; + + hashNode + .Should().NotBeNull(); + + hashNode.GetValue() + .Should().Be(TemplatesLockEntry.ComputeHash(writtenBytes), path) + .And.MatchRegex("^sha256:[0-9a-f]{64}$"); + } + } + + [Fact] + [Trait("Requirement", "FP02-AC4")] + public async Task InitializeAsync_NoGitIgnore_GitIgnoreContainsEachEntryExactlyOnce() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + + // Act + await CreateInitializer().InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + var lines = tempDirectory.ReadFileText(GitIgnore).Split('\n'); + + foreach (var entry in KitGitIgnoreEntries.All) + { + lines.Count(x => x == entry) + .Should().Be(1, entry); + } + } + + [Theory] + [Trait("Requirement", "FP02-AC4")] + [InlineData("bin/\n.shadekit/local.json\n.shadekit/features/current-feature.json\n")] + [InlineData("/.shadekit/local.json\r\n/.shadekit/features/current-feature.json")] + public async Task InitializeAsync_GitIgnoreHasEntries_LeavesGitIgnoreUnchanged(string gitIgnoreText) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.WriteFile(gitIgnoreText, GitIgnore); + var original = tempDirectory.ReadFileBytes(GitIgnore); + + // Act + await CreateInitializer().InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileBytes(GitIgnore) + .Should().Equal(original); + } + + [Fact] + [Trait("Requirement", "FP02-AC4")] + public async Task InitializeAsync_GitIgnoreHasOneEntry_AppendsOnlyTheMissingEntry() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.WriteFile("bin/\n/.shadekit/local.json\n", GitIgnore); + + // Act + await CreateInitializer().InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileText(GitIgnore) + .Should().Be("bin/\n/.shadekit/local.json\n\n# SHADE kit: local-only files\n" + + ".shadekit/features/current-feature.json\n"); + } + + [Theory] + [Trait("Requirement", "FP02-AC5")] + [MemberData(nameof(InstructionFileNames))] + public async Task InitializeAsync_NoInstructionFile_CreatesFileWithOnlyTheSection(string fileName) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + + // Act + await CreateInitializer().InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileBytes(fileName) + .Should().Equal(Encoding.UTF8.GetBytes(ExpectedSectionFile)); + } + + [Theory] + [Trait("Requirement", "FP02-AC5")] + [MemberData(nameof(InstructionFileNames))] + public async Task InitializeAsync_InstructionFileWithContent_AppendsExactlyOneSectionAndKeepsContent( + string fileName) + { + // Arrange + const string existingText = "# Project rules\n\nUse tabs."; + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.WriteFile(existingText, fileName); + + // Act + await CreateInitializer().InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + var text = tempDirectory.ReadFileText(fileName); + + text + .Should().Be(existingText + "\n\n" + ExpectedSectionFile); + + TestText.CountLinesStartingWith(text, ManagedInstructionSection.BeginMarkerPrefix) + .Should().Be(1); + + TestText.CountLinesStartingWith(text, ManagedInstructionSection.EndMarker) + .Should().Be(1); + } + + [Fact] + [Trait("Requirement", "FP02-AC5")] + public async Task InitializeAsync_AgentsMdIsSymlinkToClaudeMd_AppendsExactlyOneSectionAndKeepsSymlink() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.WriteFile("# Project\n", ClaudeMd); + var agentsMdPath = tempDirectory.CreateSymbolicLinkOrSkip(ClaudeMd, AgentsMd); + + // Act + var result = await CreateInitializer() + .InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileText(ClaudeMd) + .Should().Be("# Project\n\n" + ExpectedSectionFile); + + new FileInfo(agentsMdPath).LinkTarget + .Should().Be(ClaudeMd); + + result.Files.TakeLast(2) + .Should().Equal(Created(GitIgnore), new InitializedFile(ClaudeMd, InitializedFileAction.Appended)); + } + + [Fact] + [Trait("Requirement", "FP02-AC5")] + public async Task InitializeAsync_AgentsMdIsSymlinkToClaudeMdWithOutdatedSection_UpdatesSectionOnceAndKeepsSymlink() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.WriteFile("# Project\n\nold\n\nrest\n", ClaudeMd); + var agentsMdPath = tempDirectory.CreateSymbolicLinkOrSkip(ClaudeMd, AgentsMd); + + // Act + var result = await CreateInitializer() + .InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileText(ClaudeMd) + .Should().Be("# Project\n" + ExpectedSectionFile + "rest\n"); + + new FileInfo(agentsMdPath).LinkTarget + .Should().Be(ClaudeMd); + + result.Files.TakeLast(2) + .Should().Equal(Created(GitIgnore), new InitializedFile(ClaudeMd, InitializedFileAction.Updated)); + } + + [Theory] + [Trait("Requirement", "FP02-AC5")] + [MemberData(nameof(InstructionFileNames))] + public async Task InitializeAsync_InstructionFileWithOutdatedSection_ReplacesSectionAndKeepsOtherContent( + string fileName) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.WriteFile("# Before\n\nold\n\n# After\n", + fileName); + + // Act + await CreateInitializer().InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileText(fileName) + .Should().Be("# Before\n" + ExpectedSectionFile + "# After\n"); + } + + [Fact] + [Trait("Requirement", "FP02-AC6")] + public async Task InitializeAsync_AlreadyInitialised_ThrowsKitAlreadyInitializedExceptionAndChangesNoFile() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + var initializer = CreateInitializer(); + await initializer.InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + var snapshot = tempDirectory.TakeSnapshot(); + + // Act + var act = () => initializer.InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + var exception = (await act.Should().ThrowAsync()).Which; + + exception.KitRootPath + .Should().Be(Path.Combine(tempDirectory.RootPath, KitRoot)); + + exception.Message + .Should().Contain(exception.KitRootPath) + .And.NotContain("shade-kit upgrade"); + + tempDirectory.TakeSnapshot() + .Should().BeEquivalentTo(snapshot); + } + + [Fact] + [Trait("Requirement", "FP02-AC6")] + public async Task InitializeAsync_KitRootIsEmptyDirectory_ThrowsKitAlreadyInitializedException() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.CreateDirectory(KitRoot); + + var snapshot = tempDirectory.TakeSnapshot(); + + // Act + var act = () => CreateInitializer() + .InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + (await act.Should().ThrowAsync()) + .Which.KitRootPath.Should().Be(Path.Combine(tempDirectory.RootPath, KitRoot)); + + tempDirectory.TakeSnapshot() + .Should().BeEquivalentTo(snapshot); + } + + [Fact] + public async Task InitializeAsync_KitRootIsFile_ThrowsKitRootNotADirectoryExceptionAndChangesNothing() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.WriteFile("not a directory", KitRoot); + + var snapshot = tempDirectory.TakeSnapshot(); + + // Act + var act = () => CreateInitializer() + .InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + (await act.Should().ThrowAsync()) + .Which.KitRootPath.Should().Be(Path.Combine(tempDirectory.RootPath, KitRoot)); + + tempDirectory.TakeSnapshot() + .Should().BeEquivalentTo(snapshot); + } + + [Fact] + [Trait("Requirement", "FP02-AC7")] + public async Task InitializeAsync_NotInsideGitRepository_ThrowsNotAGitRepositoryExceptionAndCreatesNothing() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.SkipWhenGitRootExistsAbove(); + + // Act + var act = () => CreateInitializer() + .InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + (await act.Should().ThrowAsync()) + .Which.StartPath.Should().Be(tempDirectory.RootPath); + + Directory.EnumerateFileSystemEntries(tempDirectory.RootPath) + .Should().BeEmpty(); + } + + [Theory] + [Trait("Requirement", "FP02-AC8")] + [InlineData("spec.template.md", ".shadekit/templates/spec.md")] + [InlineData("checks/definition-of-ready.template.md", ".shadekit/templates/checks/definition-of-ready.md")] + public async Task InitializeAsync_ShippedTemplate_WritesReaderContentToRepositoryPath(string resourcePath, + string repositoryPath) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + var shippedTemplate = ShippedTemplates.All.Single(x => x.ResourcePath == resourcePath); + + // Act + await CreateInitializer().InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileText(repositoryPath.Split('/')) + .Should().Be(new ShippedTemplateReader().ReadContent(shippedTemplate)); + } + + [Fact] + [Trait("Requirement", "FP02-AC8")] + public async Task InitializeAsync_EveryShippedTemplate_WritesReaderContentAsUtf8WithoutBomAndCarriageReturn() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + var reader = new ShippedTemplateReader(); + + // Act + await CreateInitializer().InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + foreach (var shippedTemplate in ShippedTemplates.All) + { + var writtenBytes = tempDirectory.ReadFileBytes(shippedTemplate.RepositoryPath.Split('/')); + + writtenBytes + .Should().Equal(new UTF8Encoding(false).GetBytes(reader.ReadContent(shippedTemplate))); + + writtenBytes.Take(3) + .Should().NotEqual(new byte[] { 0xEF, 0xBB, 0xBF }, shippedTemplate.RepositoryPath); + + writtenBytes + .Should().NotContain((byte)'\r', shippedTemplate.RepositoryPath); + } + } + + [Fact] + public async Task InitializeAsync_PathIsSubdirectory_InitialisesAtRepositoryRoot() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + var subdirectoryPath = tempDirectory.CreateDirectory("src", "app"); + + // Act + var result = await CreateInitializer() + .InitializeAsync(subdirectoryPath, TestContext.Current.CancellationToken); + + // Assert + result.RepositoryRootPath + .Should().Be(tempDirectory.RootPath); + + Directory.Exists(Path.Combine(tempDirectory.RootPath, KitRoot)) + .Should().BeTrue(); + + File.Exists(Path.Combine(tempDirectory.RootPath, GitIgnore)) + .Should().BeTrue(); + + Directory.EnumerateFileSystemEntries(subdirectoryPath) + .Should().BeEmpty(); + } + + [Theory] + [MemberData(nameof(InstructionFileNames))] + public async Task InitializeAsync_InvalidMarkersInInstructionFile_ThrowsAndCreatesOrChangesNothing( + string invalidFileName) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.WriteFile("# Valid file\n", ClaudeMd); + tempDirectory.WriteFile("# Valid file\n", AgentsMd); + tempDirectory.WriteFile("# Broken\n\nno end marker\n", invalidFileName); + var snapshot = tempDirectory.TakeSnapshot(); + + // Act + var act = () => CreateInitializer() + .InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + (await act.Should().ThrowAsync()) + .Which.FilePath.Should().Be(Path.Combine(tempDirectory.RootPath, invalidFileName)); + + tempDirectory.TakeSnapshot() + .Should().BeEquivalentTo(snapshot); + + Path.Exists(Path.Combine(tempDirectory.RootPath, KitRoot)) + .Should().BeFalse(); + } + + [Theory] + [MemberData(nameof(RepositoryFileNames))] + public async Task InitializeAsync_Utf16RepositoryFile_ThrowsUnsupportedFileEncodingExceptionAndWritesNothing( + string utf16FileName) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.WriteFile("# Valid file\n", ClaudeMd); + tempDirectory.WriteFile("# Valid file\n", AgentsMd); + tempDirectory.WriteFileBytes([0xFF, 0xFE, .. Encoding.Unicode.GetBytes("text\r\n")], utf16FileName); + var snapshot = tempDirectory.TakeSnapshot(); + + // Act + var act = () => CreateInitializer() + .InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + var exception = (await act.Should().ThrowAsync()).Which; + + exception.FilePath + .Should().Be(Path.Combine(tempDirectory.RootPath, utf16FileName)); + + exception.EncodingName + .Should().Be("UTF-16 LE"); + + tempDirectory.TakeSnapshot() + .Should().BeEquivalentTo(snapshot); + + Path.Exists(Path.Combine(tempDirectory.RootPath, KitRoot)) + .Should().BeFalse(); + } + + [Theory] + [MemberData(nameof(RepositoryFileNames))] + public async Task InitializeAsync_Utf8RepositoryFileWithBom_ChangesFileAndKeepsBom(string fileName) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + byte[] original = [0xEF, 0xBB, 0xBF, .. "# Existing\n"u8]; + tempDirectory.WriteFileBytes(original, fileName); + + // Act + var result = await CreateInitializer() + .InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + result.Files + .Should().ContainEquivalentOf(new InitializedFile(fileName, InitializedFileAction.Appended)); + + var bytes = tempDirectory.ReadFileBytes(fileName); + + bytes.Take(original.Length) + .Should().Equal(original); + + bytes.Length + .Should().BeGreaterThan(original.Length); + } + + [Theory] + [MemberData(nameof(RepositoryFileNames))] + public async Task InitializeAsync_RepositoryFileIsDirectory_ThrowsPathNotAFileExceptionAndWritesNothing( + string directoryName) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.CreateDirectory(directoryName); + var snapshot = tempDirectory.TakeSnapshot(); + + // Act + var act = () => CreateInitializer() + .InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + (await act.Should().ThrowAsync()) + .Which.Path.Should().Be(Path.Combine(tempDirectory.RootPath, directoryName)); + + tempDirectory.TakeSnapshot() + .Should().BeEquivalentTo(snapshot); + + Path.Exists(Path.Combine(tempDirectory.RootPath, KitRoot)) + .Should().BeFalse(); + } + + [Fact] + public async Task InitializeAsync_GitRepositoryWithoutKitRoot_StoreReadsWrittenFilesWithoutWarnings() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + var cancellationToken = TestContext.Current.CancellationToken; + + // Act + await CreateInitializer().InitializeAsync(tempDirectory.RootPath, cancellationToken); + + // Assert + var kitStore = CreateKitStoreFactory().Create(tempDirectory.RootPath); + + var config = await kitStore.ReadJsonAsync(KitStorePaths.ConfigFileName, FormatIds.Config, + FormatAccess.Read, cancellationToken); + var gates = await kitStore.ReadJsonAsync(KitStorePaths.GatesFileName, FormatIds.Gates, + FormatAccess.Read, cancellationToken); + var templatesLock = await kitStore.ReadJsonAsync(KitStorePaths.TemplatesLockFileName, + FormatIds.TemplatesLock, FormatAccess.Read, cancellationToken); + + config + .Should().NotBeNull(); + + config.Warnings + .Should().BeEmpty(); + + config.Document.KitVersion + .Should().Be(EngineVersion); + + config.Document.Trackers + .Should().Equal(new Dictionary { ["github.com"] = "gh" }); + + config.Document.CoverageThreshold + .Should().BeNull(); + + gates + .Should().NotBeNull(); + + gates.Warnings + .Should().BeEmpty(); + + templatesLock + .Should().NotBeNull(); + + templatesLock.Warnings + .Should().BeEmpty(); + + templatesLock.Document.Templates + .Should().HaveCount(ShippedTemplates.All.Count); + + kitStore.GetFeatureNames() + .Should().BeEmpty(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task InitializeAsync_NullEmptyOrWhitespacePath_ThrowsArgumentException(string? path) + { + // Arrange + var initializer = CreateInitializer(); + + // Act + var act = () => initializer.InitializeAsync(path!, TestContext.Current.CancellationToken); + + // Assert + (await act.Should().ThrowAsync()) + .WithParameterName(nameof(path)); + } + + [Fact] + public async Task InitializeAsync_CanceledToken_ThrowsOperationCanceledExceptionAndCreatesNothing() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + var snapshot = tempDirectory.TakeSnapshot(); + + // Act + var act = () => CreateInitializer().InitializeAsync(tempDirectory.RootPath, cancellationTokenSource.Token); + + // Assert + await act.Should().ThrowAsync(); + + tempDirectory.TakeSnapshot() + .Should().BeEquivalentTo(snapshot); + } + + [Fact] + public async Task InitializeAsync_TokenCanceledDuringPreconditions_ThrowsOperationCanceledExceptionAndWritesNothing() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + using var cancellationTokenSource = new CancellationTokenSource(); + var snapshot = tempDirectory.TakeSnapshot(); + + // Every plan passes and cancels the token without observing it, so the preconditions complete and the + // cancellation must stop the initialisation before its first write, whichever plan runs last. + var gitIgnorePlanner = A.Fake(); + A.CallTo(() => gitIgnorePlanner.PlanAsync(A._, A._)) + .Invokes(() => cancellationTokenSource.Cancel()) + .Returns(Task.FromResult(null)); + + var instructionSectionPlanner = A.Fake(); + A.CallTo(() => instructionSectionPlanner.PlanAsync(A._, A._)) + .Invokes(() => cancellationTokenSource.Cancel()) + .Returns(Task.FromResult(null)); + + var initializer = CreateInitializer(gitIgnorePlanner, instructionSectionPlanner); + + // Act + var act = () => initializer.InitializeAsync(tempDirectory.RootPath, cancellationTokenSource.Token); + + // Assert + await act.Should().ThrowAsync(); + + Path.Exists(Path.Combine(tempDirectory.RootPath, KitRoot)) + .Should().BeFalse(); + + tempDirectory.TakeSnapshot() + .Should().BeEquivalentTo(snapshot); + } + + [Fact] + public async Task InitializeAsync_LastWriteFails_RemovesKitRootAndKeepsRepositoryFilesWrittenBefore() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + + // AGENTS.md is written last; a read-only file can be planned but not written. + var agentsPath = tempDirectory.WriteFile("# Agents\n", AgentsMd); + File.SetAttributes(agentsPath, FileAttributes.ReadOnly); + SkipWhenWritable(agentsPath); + + // Act + var act = () => CreateInitializer().InitializeAsync(tempDirectory.RootPath, + TestContext.Current.CancellationToken); + + // Assert + try + { + await act.Should().ThrowAsync(); + + Path.Exists(Path.Combine(tempDirectory.RootPath, KitRoot)) + .Should().BeFalse(); + + File.Exists(Path.Combine(tempDirectory.RootPath, GitIgnore)) + .Should().BeTrue(); + + tempDirectory.ReadFileText(ClaudeMd) + .Should().Be(ExpectedSectionFile); + + tempDirectory.ReadFileText(AgentsMd) + .Should().Be("# Agents\n"); + } + finally + { + File.SetAttributes(agentsPath, FileAttributes.Normal); + } + } + + [Fact] + public async Task InitializeAsync_AfterLastWriteFailed_SecondRunCompletesOnlyTheMissingChanges() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + + var agentsPath = tempDirectory.WriteFile("# Agents\n", AgentsMd); + File.SetAttributes(agentsPath, FileAttributes.ReadOnly); + SkipWhenWritable(agentsPath); + + try + { + var firstRun = () => CreateInitializer().InitializeAsync(tempDirectory.RootPath, + TestContext.Current.CancellationToken); + await firstRun.Should().ThrowAsync(); + } + finally + { + File.SetAttributes(agentsPath, FileAttributes.Normal); + } + + // Act + var result = await CreateInitializer().InitializeAsync(tempDirectory.RootPath, + TestContext.Current.CancellationToken); + + // Assert + result.Files.Where(file => !file.Path.StartsWith(KitRoot + "/", StringComparison.Ordinal)) + .Should().Equal(new InitializedFile(AgentsMd, InitializedFileAction.Appended)); + + File.Exists(Path.Combine(tempDirectory.RootPath, KitRoot, "templates.lock.json")) + .Should().BeTrue(); + + tempDirectory.ReadFileText(ClaudeMd) + .Should().Be(ExpectedSectionFile); + } + + [Fact] + public async Task InitializeAsync_WriteInKitRootFails_RemovesKitRootAndWritesNoRepositoryFile() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + + var shippedTemplateReader = A.Fake(); + A.CallTo(() => shippedTemplateReader.ReadContent(A._)).Throws(new IOException("disk full")); + + var initializer = CreateInitializer(shippedTemplateReader); + var snapshot = tempDirectory.TakeSnapshot(); + + // Act + var act = () => initializer.InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + (await act.Should().ThrowAsync()) + .WithMessage("disk full"); + + tempDirectory.TakeSnapshot() + .Should().BeEquivalentTo(snapshot); + } + + [Fact] + public async Task InitializeAsync_FailsBeforeKitRootIsCreated_RethrowsOriginalExceptionAndWritesNothing() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + + var engineVersionProvider = A.Fake(); + A.CallTo(() => engineVersionProvider.Version).Throws(new InvalidOperationException("no version")); + + var initializer = new RepositoryInitializer(new GitRepositoryLocator(), CreateKitStoreFactory(), + new ShippedTemplateReader(), new GitIgnorePlanner(), new InstructionSectionPlanner(), engineVersionProvider); + var snapshot = tempDirectory.TakeSnapshot(); + + // Act + var act = () => initializer.InitializeAsync(tempDirectory.RootPath, TestContext.Current.CancellationToken); + + // Assert + (await act.Should().ThrowAsync()) + .WithMessage("no version"); + + tempDirectory.TakeSnapshot() + .Should().BeEquivalentTo(snapshot); + } + + [Fact] + [UnsupportedOSPlatform("windows")] + public async Task InitializeAsync_RollbackFails_RethrowsWriteErrorAndNextRunReportsAlreadyInitialized() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix file modes are needed to make the kit root read-only."); + Assert.SkipWhen(Environment.UserName == "root", "root can write to a read-only directory."); + + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + var kitRootPath = Path.Combine(tempDirectory.RootPath, KitRoot); + + // The first template is read after config.json and gates.json are written. A read-only kit root makes the + // template write fail and keeps the rollback from removing the files in it. + var shippedTemplateReader = A.Fake(); + A.CallTo(() => shippedTemplateReader.ReadContent(A._)) + .Invokes(() => File.SetUnixFileMode(kitRootPath, UnixFileMode.UserRead | UnixFileMode.UserExecute)) + .Returns("template"); + + try + { + // Act + var act = () => CreateInitializer(shippedTemplateReader).InitializeAsync(tempDirectory.RootPath, + TestContext.Current.CancellationToken); + + // Assert + await act.Should().ThrowAsync(); + + Directory.Exists(kitRootPath) + .Should().BeTrue(); + + var secondRun = () => CreateInitializer().InitializeAsync(tempDirectory.RootPath, + TestContext.Current.CancellationToken); + + await secondRun.Should().ThrowAsync(); + } + finally + { + File.SetUnixFileMode(kitRootPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } + + [Fact] + public async Task InitializeAsync_CanceledWhileWritingKitRoot_RemovesKitRoot() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + using var cancellationTokenSource = new CancellationTokenSource(); + + // The first template is read after config.json and gates.json are written; the cancellation stops the write + // of the template. + var shippedTemplateReader = A.Fake(); + A.CallTo(() => shippedTemplateReader.ReadContent(A._)) + .Invokes(() => cancellationTokenSource.Cancel()) + .Returns("template"); + + var initializer = CreateInitializer(shippedTemplateReader); + var snapshot = tempDirectory.TakeSnapshot(); + + // Act + var act = () => initializer.InitializeAsync(tempDirectory.RootPath, cancellationTokenSource.Token); + + // Assert + await act.Should().ThrowAsync(); + + tempDirectory.TakeSnapshot() + .Should().BeEquivalentTo(snapshot); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + public void Constructor_NullDependency_ThrowsArgumentNullException(int nullIndex) + { + // Arrange + object?[] dependencies = + [ + A.Fake(), + A.Fake(), + A.Fake(), + A.Fake(), + A.Fake(), + A.Fake() + ]; + dependencies[nullIndex] = null; + + // Act + var act = () => new RepositoryInitializer( + (IGitRepositoryLocator)dependencies[0]!, + (IKitStoreFactory)dependencies[1]!, + (IShippedTemplateReader)dependencies[2]!, + (IGitIgnorePlanner)dependencies[3]!, + (IInstructionSectionPlanner)dependencies[4]!, + (IEngineVersionProvider)dependencies[5]!); + + // Assert + act.Should().Throw(); + } + + /// + /// Skips the current test if the process can still write a read-only file, for example when it runs as root. + /// + private static void SkipWhenWritable(string filePath) + { + try + { + File.OpenWrite(filePath).Dispose(); + } + catch (UnauthorizedAccessException) + { + return; + } + + File.SetAttributes(filePath, FileAttributes.Normal); + Assert.Skip($"The read-only file '{filePath}' is writable by this process; a failing write cannot be tested."); + } + + private static RepositoryInitializer CreateInitializer() + { + return CreateInitializer(new GitIgnorePlanner(), new InstructionSectionPlanner()); + } + + private static RepositoryInitializer CreateInitializer(IGitIgnorePlanner gitIgnorePlanner, + IInstructionSectionPlanner instructionSectionPlanner) + { + return CreateInitializer(new ShippedTemplateReader(), gitIgnorePlanner, instructionSectionPlanner); + } + + private static RepositoryInitializer CreateInitializer(IShippedTemplateReader shippedTemplateReader) + { + return CreateInitializer(shippedTemplateReader, new GitIgnorePlanner(), new InstructionSectionPlanner()); + } + + private static RepositoryInitializer CreateInitializer(IShippedTemplateReader shippedTemplateReader, + IGitIgnorePlanner gitIgnorePlanner, IInstructionSectionPlanner instructionSectionPlanner) + { + var engineVersionProvider = A.Fake(); + A.CallTo(() => engineVersionProvider.Version).Returns(EngineVersion); + + return new RepositoryInitializer(new GitRepositoryLocator(), CreateKitStoreFactory(), + shippedTemplateReader, gitIgnorePlanner, instructionSectionPlanner, engineVersionProvider); + } + + private static KitStoreFactory CreateKitStoreFactory() + { + return new KitStoreFactory(new GitRepositoryLocator(), new VersionedJsonSerializer(Registry), Registry); + } + + private static string GetMinEngineVersion(string formatId) + { + var format = Registry.GetFormat(formatId); + + return format.GetMinEngineVersion(format.CurrentVersion); + } + + private static InitializedFile Created(string path) + { + return new InitializedFile(path, InitializedFileAction.Created); + } + + private static IEnumerable GetRelativeFilePaths(string rootPath, string directoryPath) + { + return Directory.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories) + .Select(x => Path.GetRelativePath(rootPath, x).Replace(Path.DirectorySeparatorChar, '/')); + } +} diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/ShadeKitCoreServiceCollectionExtensionsTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/ShadeKitCoreServiceCollectionExtensionsTests.cs index aa4ecf8..dc4cc5c 100644 --- a/tests/CreativeCoders.ShadeKit.Core.Tests/ShadeKitCoreServiceCollectionExtensionsTests.cs +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/ShadeKitCoreServiceCollectionExtensionsTests.cs @@ -1,7 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using AwesomeAssertions; using CreativeCoders.ShadeKit.Core.Engine; using CreativeCoders.ShadeKit.Core.Formats; +using CreativeCoders.ShadeKit.Core.Init; using CreativeCoders.ShadeKit.Core.Store; +using CreativeCoders.ShadeKit.Core.Templates; using FakeItEasy; using Microsoft.Extensions.DependencyInjection; @@ -44,9 +47,14 @@ public void AddShadeKitCore_ServiceCollection_RegistersKitStoreFactoryAsSingleto } [Theory] + [InlineData(typeof(IGitRepositoryLocator), typeof(GitRepositoryLocator))] [InlineData(typeof(IFormatRegistry), typeof(FormatRegistry))] [InlineData(typeof(IVersionedJsonSerializer), typeof(VersionedJsonSerializer))] [InlineData(typeof(IEngineVersionProvider), typeof(EngineVersionProvider))] + [InlineData(typeof(IRepositoryInitializer), typeof(RepositoryInitializer))] + [InlineData(typeof(IShippedTemplateReader), typeof(ShippedTemplateReader))] + [InlineData(typeof(IGitIgnorePlanner), typeof(GitIgnorePlanner))] + [InlineData(typeof(IInstructionSectionPlanner), typeof(InstructionSectionPlanner))] public void AddShadeKitCore_ServiceCollection_RegistersServiceAsSingleton(Type serviceType, Type implementationType) { // Arrange @@ -124,8 +132,13 @@ public void AddShadeKitCore_ExistingFormatRegistryRegistration_SerializerUsesExi } [Theory] + [InlineData(typeof(IGitRepositoryLocator))] [InlineData(typeof(IVersionedJsonSerializer))] [InlineData(typeof(IEngineVersionProvider))] + [InlineData(typeof(IRepositoryInitializer))] + [InlineData(typeof(IShippedTemplateReader))] + [InlineData(typeof(IGitIgnorePlanner))] + [InlineData(typeof(IInstructionSectionPlanner))] public void AddShadeKitCore_ExistingServiceRegistration_KeepsExistingRegistration(Type serviceType) { // Arrange @@ -144,9 +157,14 @@ public void AddShadeKitCore_ExistingServiceRegistration_KeepsExistingRegistratio } [Theory] + [InlineData(typeof(IGitRepositoryLocator))] [InlineData(typeof(IFormatRegistry))] [InlineData(typeof(IVersionedJsonSerializer))] [InlineData(typeof(IEngineVersionProvider))] + [InlineData(typeof(IRepositoryInitializer))] + [InlineData(typeof(IShippedTemplateReader))] + [InlineData(typeof(IGitIgnorePlanner))] + [InlineData(typeof(IInstructionSectionPlanner))] public void AddShadeKitCore_CalledTwice_RegistersServiceOnlyOnce(Type serviceType) { // Arrange @@ -207,6 +225,31 @@ public void AddShadeKitCore_ExistingKitStoreFactoryRegistration_KeepsExistingReg .Should().BeSameAs(existingFactory); } + [Fact] + public void AddShadeKitCore_ExistingGitRepositoryLocatorRegistration_KitStoreFactoryUsesExistingLocator() + { + // Arrange + var existingLocator = A.Fake(); + A.CallTo(() => existingLocator.GetRepositoryRootPath("/start")) + .Throws(new NotAGitRepositoryException("/start")); + + var services = new ServiceCollection(); + services.AddSingleton(existingLocator); + + // Act + services.AddShadeKitCore(); + + // Assert + using var serviceProvider = services.BuildServiceProvider(); + + var act = () => serviceProvider.GetRequiredService().Create("/start"); + + act.Should().Throw(); + + A.CallTo(() => existingLocator.GetRepositoryRootPath("/start")) + .MustHaveHappenedOnceExactly(); + } + [Fact] public void AddShadeKitCore_NullServiceCollection_ThrowsArgumentNullException() { @@ -221,5 +264,6 @@ public void AddShadeKitCore_NullServiceCollection_ThrowsArgumentNullException() .WithParameterName("services"); } + [SuppressMessage("csharpsquid", "S2094", Justification = "Test class for serialization")] public sealed class TestDocument : VersionedDocument; } diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Store/GitRepositoryLocatorCurrentDirectoryTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Store/GitRepositoryLocatorCurrentDirectoryTests.cs new file mode 100644 index 0000000..9158dee --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Store/GitRepositoryLocatorCurrentDirectoryTests.cs @@ -0,0 +1,28 @@ +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Store; +using CreativeCoders.ShadeKit.Tests.Shared; + +namespace CreativeCoders.ShadeKit.Core.Tests.Store; + +[Collection(CurrentDirectoryCollectionDefinition.Name)] +public class GitRepositoryLocatorCurrentDirectoryTests +{ + [Fact] + public void GetRepositoryRootPath_RelativePath_ResolvesAgainstCurrentDirectory() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.CreateDirectory("src", "app"); + + var locator = new GitRepositoryLocator(); + using var currentDirectory = new CurrentDirectoryScope(tempDirectory.RootPath); + + // Act + var rootPath = locator.GetRepositoryRootPath(Path.Combine("src", "app")); + + // Assert + rootPath + .Should().Be(Directory.GetCurrentDirectory()); + } +} diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Store/GitRepositoryLocatorTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Store/GitRepositoryLocatorTests.cs new file mode 100644 index 0000000..d0756cc --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Store/GitRepositoryLocatorTests.cs @@ -0,0 +1,216 @@ +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Store; +using CreativeCoders.ShadeKit.Tests.Shared; + +namespace CreativeCoders.ShadeKit.Core.Tests.Store; + +public class GitRepositoryLocatorTests +{ + [Fact] + public void GetRepositoryRootPath_PathIsRepositoryRoot_ReturnsPath() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + + var locator = new GitRepositoryLocator(); + + // Act + var rootPath = locator.GetRepositoryRootPath(tempDirectory.RootPath); + + // Assert + rootPath + .Should().Be(tempDirectory.RootPath); + } + + [Theory] + [Trait("Requirement", "FP01-AC1")] + [InlineData(1)] + [InlineData(3)] + [InlineData(25)] + public void GetRepositoryRootPath_PathNestedBelowRepositoryRoot_ReturnsRepositoryRoot(int depth) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + var nestedPath = tempDirectory.CreateDirectory(CreateNestedPathParts(depth)); + + var locator = new GitRepositoryLocator(); + + // Act + var rootPath = locator.GetRepositoryRootPath(nestedPath); + + // Assert + rootPath + .Should().Be(tempDirectory.RootPath); + } + + [Fact] + [Trait("Requirement", "FP01-AC1")] + public void GetRepositoryRootPath_GitEntryIsAFile_ReturnsDirectoryContainingTheFile() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.WriteFile("gitdir: ../main/.git/worktrees/feature", ".git"); + var nestedPath = tempDirectory.CreateDirectory("src"); + + var locator = new GitRepositoryLocator(); + + // Act + var rootPath = locator.GetRepositoryRootPath(nestedPath); + + // Assert + rootPath + .Should().Be(tempDirectory.RootPath); + } + + [Fact] + public void GetRepositoryRootPath_NestedRepository_ReturnsNearestRepositoryRoot() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + var submodulePath = tempDirectory.CreateGitRoot("modules", "lib"); + var nestedPath = tempDirectory.CreateDirectory("modules", "lib", "src"); + + var locator = new GitRepositoryLocator(); + + // Act + var rootPath = locator.GetRepositoryRootPath(nestedPath); + + // Assert + rootPath + .Should().Be(submodulePath); + } + + [Fact] + public void GetRepositoryRootPath_PathNextToNestedRepository_ReturnsOuterRepositoryRoot() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.CreateGitRoot("modules", "lib"); + var siblingPath = tempDirectory.CreateDirectory("modules", "app"); + + var locator = new GitRepositoryLocator(); + + // Act + var rootPath = locator.GetRepositoryRootPath(siblingPath); + + // Assert + rootPath + .Should().Be(tempDirectory.RootPath); + } + + [Fact] + public void GetRepositoryRootPath_PathWithTrailingSeparator_ReturnsRootWithoutTrailingSeparator() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + + var locator = new GitRepositoryLocator(); + + // Act + var rootPath = locator.GetRepositoryRootPath(tempDirectory.RootPath + Path.DirectorySeparatorChar); + + // Assert + rootPath + .Should().Be(tempDirectory.RootPath); + } + + [Fact] + public void GetRepositoryRootPath_PathWithParentSegments_ReturnsNormalizedRepositoryRoot() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + tempDirectory.CreateDirectory("src", "app"); + var path = Path.Combine(tempDirectory.RootPath, "src", "app", "..", ".."); + + var locator = new GitRepositoryLocator(); + + // Act + var rootPath = locator.GetRepositoryRootPath(path); + + // Assert + rootPath + .Should().Be(tempDirectory.RootPath); + } + + [Fact] + public void GetRepositoryRootPath_StartDirectoryDoesNotExist_ReturnsRootOfRepositoryAroundIt() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateGitRoot(); + var missingPath = Path.Combine(tempDirectory.RootPath, "src", "missing"); + + var locator = new GitRepositoryLocator(); + + // Act + var rootPath = locator.GetRepositoryRootPath(missingPath); + + // Assert + rootPath + .Should().Be(tempDirectory.RootPath); + } + + [Fact] + public void GetRepositoryRootPath_NotInsideGitRepository_ThrowsNotAGitRepositoryExceptionWithStartPath() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.SkipWhenGitRootExistsAbove(); + var nestedPath = tempDirectory.CreateDirectory("src"); + + var locator = new GitRepositoryLocator(); + + // Act + var act = () => locator.GetRepositoryRootPath(nestedPath); + + // Assert + act.Should().Throw() + .Which.StartPath.Should().Be(nestedPath); + } + + [Fact] + public void GetRepositoryRootPath_GitEntryOnlyBelowPath_ThrowsNotAGitRepositoryException() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.SkipWhenGitRootExistsAbove(); + tempDirectory.CreateGitRoot("inner"); + + var locator = new GitRepositoryLocator(); + + // Act + var act = () => locator.GetRepositoryRootPath(tempDirectory.RootPath); + + // Assert + act.Should().Throw() + .Which.StartPath.Should().Be(tempDirectory.RootPath); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetRepositoryRootPath_NullEmptyOrWhitespacePath_ThrowsArgumentException(string? path) + { + // Arrange + var locator = new GitRepositoryLocator(); + + // Act + var act = () => locator.GetRepositoryRootPath(path!); + + // Assert + act.Should().Throw() + .WithParameterName(nameof(path)); + } + + private static string[] CreateNestedPathParts(int depth) + { + return [.. Enumerable.Range(1, depth).Select(level => $"d{level}")]; + } +} diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreFactoryCurrentDirectoryTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreFactoryCurrentDirectoryTests.cs index 8cec0ac..05f13e5 100644 --- a/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreFactoryCurrentDirectoryTests.cs +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreFactoryCurrentDirectoryTests.cs @@ -1,5 +1,7 @@ using AwesomeAssertions; using CreativeCoders.ShadeKit.Core.Store; +using CreativeCoders.ShadeKit.Tests.Shared; +using FakeItEasy; namespace CreativeCoders.ShadeKit.Core.Tests.Store; @@ -17,23 +19,14 @@ public void Create_CurrentDirectoryBelowRepositoryRoot_ReturnsStoreOfKitRoot() var nestedPath = tempDirectory.CreateDirectory("src"); var factory = KitStoreFactoryTests.CreateFactory(); - var originalDirectory = Directory.GetCurrentDirectory(); - - try - { - Directory.SetCurrentDirectory(nestedPath); - - // Act - var kitStore = factory.Create(); - - // Assert - kitStore.GetFeatureNames() - .Should().Equal("auth"); - } - finally - { - Directory.SetCurrentDirectory(originalDirectory); - } + using var currentDirectory = new CurrentDirectoryScope(nestedPath); + + // Act + var kitStore = factory.Create(); + + // Assert + kitStore.GetFeatureNames() + .Should().Equal("auth"); } [Fact] @@ -45,23 +38,14 @@ public void Create_RepositoryRootWithoutShadeKit_ThrowsKitRootPathNotFoundExcept tempDirectory.CreateGitRoot(); var factory = KitStoreFactoryTests.CreateFactory(); - var originalDirectory = Directory.GetCurrentDirectory(); - - try - { - Directory.SetCurrentDirectory(tempDirectory.RootPath); - - // Act - var act = () => factory.Create(); - - // Assert - act.Should().Throw() - .Which.StartPath.Should().Be(Directory.GetCurrentDirectory()); - } - finally - { - Directory.SetCurrentDirectory(originalDirectory); - } + using var currentDirectory = new CurrentDirectoryScope(tempDirectory.RootPath); + + // Act + var act = () => factory.Create(); + + // Assert + act.Should().Throw() + .Which.StartPath.Should().Be(Directory.GetCurrentDirectory()); } [Fact] @@ -74,22 +58,51 @@ public void Create_RelativePath_ResolvesAgainstCurrentDirectory() tempDirectory.CreateDirectory("src", "app"); var factory = KitStoreFactoryTests.CreateFactory(); - var originalDirectory = Directory.GetCurrentDirectory(); - - try - { - Directory.SetCurrentDirectory(tempDirectory.RootPath); - - // Act - var kitStore = factory.Create(Path.Combine("src", "app")); - - // Assert - kitStore.GetFeatureNames() - .Should().Equal("auth"); - } - finally - { - Directory.SetCurrentDirectory(originalDirectory); - } + using var currentDirectory = new CurrentDirectoryScope(tempDirectory.RootPath); + + // Act + var kitStore = factory.Create(Path.Combine("src", "app")); + + // Assert + kitStore.GetFeatureNames() + .Should().Equal("auth"); + } + + [Fact] + public void Create_WithoutPath_PassesCurrentDirectoryToLocator() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.CreateDirectory(".shadekit"); + + var locator = A.Fake(); + A.CallTo(() => locator.GetRepositoryRootPath(A._)).Returns(tempDirectory.RootPath); + + var factory = KitStoreFactoryTests.CreateFactory(locator); + using var currentDirectory = new CurrentDirectoryScope(tempDirectory.RootPath); + + // Act + factory.Create(); + + // Assert + A.CallTo(() => locator.GetRepositoryRootPath(Directory.GetCurrentDirectory())) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public void CreateForRepositoryRoot_RelativePath_ReturnsStoreWithFullPathResolvedAgainstCurrentDirectory() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + + var factory = KitStoreFactoryTests.CreateFactory(); + using var currentDirectory = new CurrentDirectoryScope(tempDirectory.RootPath); + + // Act + var kitStore = factory.CreateForRepositoryRoot("."); + + // Assert + kitStore.RootPath + .Should().Be(Path.Combine(Directory.GetCurrentDirectory(), ".shadekit")); } } diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreFactoryTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreFactoryTests.cs index b9d347c..34da7bb 100644 --- a/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreFactoryTests.cs +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreFactoryTests.cs @@ -1,6 +1,9 @@ using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Config; using CreativeCoders.ShadeKit.Core.Formats; using CreativeCoders.ShadeKit.Core.Store; +using CreativeCoders.ShadeKit.Tests.Shared; +using FakeItEasy; namespace CreativeCoders.ShadeKit.Core.Tests.Store; @@ -29,49 +32,6 @@ public void Create_PathIsGitRootWithKitRoot_ReturnsStoreOfShadeKitDirectory() .Should().BeEquivalentTo("auth", "billing"); } - [Theory] - [Trait("Requirement", "FP01-AC1")] - [InlineData(1)] - [InlineData(3)] - [InlineData(25)] - public void Create_PathNestedBelowGitRoot_ReturnsStoreOfRepositoryRoot(int depth) - { - // Arrange - using var tempDirectory = new TempKitDirectory(); - tempDirectory.CreateGitRoot(); - tempDirectory.CreateDirectory(".shadekit", "features", "auth"); - var nestedPath = tempDirectory.CreateDirectory(CreateNestedPathParts(depth)); - - var factory = CreateFactory(); - - // Act - var kitStore = factory.Create(nestedPath); - - // Assert - kitStore.GetFeatureNames() - .Should().Equal("auth"); - } - - [Fact] - [Trait("Requirement", "FP01-AC1")] - public void Create_GitEntryIsAFile_TreatsDirectoryAsRepositoryRoot() - { - // Arrange - using var tempDirectory = new TempKitDirectory(); - tempDirectory.WriteFile("gitdir: ../main/.git/worktrees/feature", ".git"); - tempDirectory.CreateDirectory(".shadekit", "features", "auth"); - var nestedPath = tempDirectory.CreateDirectory("src"); - - var factory = CreateFactory(); - - // Act - var kitStore = factory.Create(nestedPath); - - // Assert - kitStore.GetFeatureNames() - .Should().Equal("auth"); - } - [Fact] public void Create_FeaturesDirectoryNextToShadeKitDirectory_IgnoresIt() { @@ -158,46 +118,94 @@ public void Create_NestedRepositoryWithoutShadeKit_StopsAtNestedRepositoryRoot() } [Fact] - public void Create_NotInsideGitRepository_ThrowsNotAGitRepositoryException() + public void Create_ShadeKitWithoutFeaturesDirectory_ReturnsStoreWithoutFeatures() { // Arrange using var tempDirectory = new TempKitDirectory(); - tempDirectory.SkipWhenGitRootExistsAbove(); - tempDirectory.CreateDirectory(".shadekit", "features", "auth"); - var nestedPath = tempDirectory.CreateDirectory("src"); + tempDirectory.CreateGitRoot(); + tempDirectory.CreateDirectory(".shadekit"); var factory = CreateFactory(); // Act - var act = () => factory.Create(nestedPath); + var kitStore = factory.Create(tempDirectory.RootPath); // Assert - act.Should().Throw() - .Which.StartPath.Should().Be(nestedPath); + kitStore.GetFeatureNames() + .Should().BeEmpty(); } [Fact] - public void Create_ShadeKitWithoutFeaturesDirectory_ReturnsStoreThatThrowsFeaturesDirectoryNotFoundOnRead() + public void Create_LocatorReturnsRepositoryRoot_ReturnsStoreOfShadeKitDirectoryAtThatRoot() { // Arrange using var tempDirectory = new TempKitDirectory(); - tempDirectory.CreateGitRoot(); - tempDirectory.CreateDirectory(".shadekit"); + tempDirectory.CreateDirectory(".shadekit", "features", "auth"); + const string startPath = "/any/start/path"; - var factory = CreateFactory(); + var locator = A.Fake(); + A.CallTo(() => locator.GetRepositoryRootPath(startPath)).Returns(tempDirectory.RootPath); + + var factory = CreateFactory(locator); // Act - var kitStore = factory.Create(tempDirectory.RootPath); + var kitStore = factory.Create(startPath); + + // Assert + kitStore.RootPath + .Should().Be(Path.Combine(tempDirectory.RootPath, ".shadekit")); + + kitStore.GetFeatureNames() + .Should().Equal("auth"); + } + + [Fact] + public void Create_LocatorReturnsRootWithoutShadeKit_ThrowsKitRootPathNotFoundExceptionWithLocatedRoot() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + const string startPath = "/any/start/path"; + + var locator = A.Fake(); + A.CallTo(() => locator.GetRepositoryRootPath(startPath)).Returns(tempDirectory.RootPath); + + var factory = CreateFactory(locator); + + // Act + var act = () => factory.Create(startPath); // Assert - var act = () => kitStore.GetFeatureNames(); + var exception = act.Should().Throw().Which; + + exception.StartPath + .Should().Be(startPath); - act.Should().Throw(); + exception.GitRootPath + .Should().Be(tempDirectory.RootPath); + } + + [Fact] + public void Create_LocatorThrowsNotAGitRepository_PropagatesException() + { + // Arrange + var notAGitRepositoryException = new NotAGitRepositoryException("/outside"); + + var locator = A.Fake(); + A.CallTo(() => locator.GetRepositoryRootPath("/outside")).Throws(notAGitRepositoryException); + + var factory = CreateFactory(locator); + + // Act + var act = () => factory.Create("/outside"); + + // Assert + act.Should().Throw() + .Which.Should().BeSameAs(notAGitRepositoryException); } [Fact] [Trait("Requirement", "FP01-AC2")] - public void Create_ShadeKitIsAFile_ThrowsKitRootPathNotFoundException() + public void Create_ShadeKitIsAFile_ThrowsKitRootNotADirectoryException() { // Arrange using var tempDirectory = new TempKitDirectory(); @@ -210,24 +218,125 @@ public void Create_ShadeKitIsAFile_ThrowsKitRootPathNotFoundException() var act = () => factory.Create(tempDirectory.RootPath); // Assert - act.Should().Throw() - .Which.GitRootPath.Should().Be(tempDirectory.RootPath); + act.Should().Throw() + .Which.KitRootPath.Should().Be(Path.Combine(tempDirectory.RootPath, ".shadekit")); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void Create_NullOrWhitespacePath_ThrowsArgumentException(string? startPath) + public void Create_NullOrWhitespacePath_ThrowsArgumentExceptionWithoutSearching(string? startPath) { // Arrange - var factory = CreateFactory(); + var locator = A.Fake(); + var factory = CreateFactory(locator); // Act var act = () => factory.Create(startPath!); // Assert - act.Should().Throw(); + act.Should().Throw() + .WithParameterName("path"); + + A.CallTo(locator).MustNotHaveHappened(); + } + + [Fact] + public void CreateForRepositoryRoot_ExistingKitRoot_ReturnsStoreOfShadeKitDirectoryWithoutSearching() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var kitRootPath = tempDirectory.CreateDirectory(".shadekit"); + tempDirectory.CreateDirectory(".shadekit", "features", "auth"); + + var locator = A.Fake(); + var factory = CreateFactory(locator); + + // Act + var kitStore = factory.CreateForRepositoryRoot(tempDirectory.RootPath); + + // Assert + kitStore.RootPath + .Should().Be(kitRootPath); + + kitStore.GetFeatureNames() + .Should().Equal("auth"); + + A.CallTo(locator).MustNotHaveHappened(); + } + + [Fact] + public void CreateForRepositoryRoot_KitRootDoesNotExist_ReturnsStoreWithoutCreatingDirectory() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var kitRootPath = Path.Combine(tempDirectory.RootPath, ".shadekit"); + + var factory = CreateFactory(); + + // Act + var kitStore = factory.CreateForRepositoryRoot(tempDirectory.RootPath); + + // Assert + kitStore.RootPath + .Should().Be(kitRootPath); + + Path.Exists(kitRootPath) + .Should().BeFalse(); + } + + [Fact] + public async Task CreateForRepositoryRoot_KitRootDoesNotExist_StoreCreatesKitRootOnFirstWrite() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var kitRootPath = Path.Combine(tempDirectory.RootPath, ".shadekit"); + + var factory = CreateFactory(); + var kitStore = factory.CreateForRepositoryRoot(tempDirectory.RootPath); + + // Act + await kitStore.WriteJsonAsync(KitStorePaths.ConfigFileName, FormatIds.Config, new KitConfig(), + FormatAccess.Write, TestContext.Current.CancellationToken); + + // Assert + File.Exists(Path.Combine(kitRootPath, KitStorePaths.ConfigFileName)) + .Should().BeTrue(); + } + + [Fact] + public void CreateForRepositoryRoot_RootWithTrailingSeparator_ReturnsStoreWithRootPathWithoutTrailingSeparator() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var kitRootPath = tempDirectory.CreateDirectory(".shadekit"); + + var factory = CreateFactory(); + + // Act + var kitStore = factory.CreateForRepositoryRoot(tempDirectory.RootPath + Path.DirectorySeparatorChar); + + // Assert + kitStore.RootPath + .Should().Be(kitRootPath); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void CreateForRepositoryRoot_NullOrWhitespacePath_ThrowsArgumentException(string? repositoryRootPath) + { + // Arrange + var factory = CreateFactory(); + + // Act + var act = () => factory.CreateForRepositoryRoot(repositoryRootPath!); + + // Assert + act.Should().Throw() + .WithParameterName(nameof(repositoryRootPath)); } [Fact] @@ -248,11 +357,25 @@ public void Create_GitRootWithTrailingSeparatorWithoutShadeKit_ReportsGitRootWit .Which.GitRootPath.Should().Be(tempDirectory.RootPath); } + [Fact] + public void Ctor_NullGitRepositoryLocator_ThrowsArgumentNullException() + { + // Arrange + var formatRegistry = FormatRegistry.CreateDefault(); + + // Act + var act = () => new KitStoreFactory(null!, new VersionedJsonSerializer(formatRegistry), formatRegistry); + + // Assert + act.Should().Throw() + .WithParameterName("gitRepositoryLocator"); + } + [Fact] public void Ctor_NullSerializer_ThrowsArgumentNullException() { // Act - var act = () => new KitStoreFactory(null!, FormatRegistry.CreateDefault()); + var act = () => new KitStoreFactory(new GitRepositoryLocator(), null!, FormatRegistry.CreateDefault()); // Assert act.Should().Throw() @@ -263,22 +386,19 @@ public void Ctor_NullSerializer_ThrowsArgumentNullException() public void Ctor_NullFormatRegistry_ThrowsArgumentNullException() { // Act - var act = () => new KitStoreFactory(new VersionedJsonSerializer(FormatRegistry.CreateDefault()), null!); + var act = () => new KitStoreFactory(new GitRepositoryLocator(), + new VersionedJsonSerializer(FormatRegistry.CreateDefault()), null!); // Assert act.Should().Throw() .WithParameterName("formatRegistry"); } - internal static KitStoreFactory CreateFactory() + internal static KitStoreFactory CreateFactory(IGitRepositoryLocator? gitRepositoryLocator = null) { var formatRegistry = FormatRegistry.CreateDefault(); - return new KitStoreFactory(new VersionedJsonSerializer(formatRegistry), formatRegistry); - } - - private static string[] CreateNestedPathParts(int depth) - { - return [.. Enumerable.Range(1, depth).Select(level => $"d{level}")]; + return new KitStoreFactory(gitRepositoryLocator ?? new GitRepositoryLocator(), + new VersionedJsonSerializer(formatRegistry), formatRegistry); } } diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreJsonTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreJsonTests.cs index e6f6d8a..b28a665 100644 --- a/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreJsonTests.cs +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreJsonTests.cs @@ -4,6 +4,7 @@ using CreativeCoders.ShadeKit.Core.Formats; using CreativeCoders.ShadeKit.Core.Store; using CreativeCoders.ShadeKit.Core.Tests.Formats; +using CreativeCoders.ShadeKit.Tests.Shared; namespace CreativeCoders.ShadeKit.Core.Tests.Store; @@ -46,7 +47,10 @@ public async Task ReadJsonAsync_CurrentVersion_ReturnsDocumentWithoutWarnings(Fo TestContext.Current.CancellationToken); // Assert - result!.Document.Title + result + .Should().NotBeNull(); + + result.Document.Title .Should().Be("Login"); result.Warnings @@ -67,7 +71,10 @@ public async Task ReadJsonAsync_CurrentVersionWithInvalidConfig_DoesNotReadConfi TestContext.Current.CancellationToken); // Assert - result!.Warnings + result + .Should().NotBeNull(); + + result.Warnings .Should().BeEmpty(); } @@ -86,7 +93,10 @@ await File.WriteAllTextAsync(Path.Combine(path, SampleFileName), StoreTestFormat TestContext.Current.CancellationToken); // Assert - result!.Document.Title + result + .Should().NotBeNull(); + + result.Document.Title .Should().Be("Login"); } @@ -104,7 +114,10 @@ public async Task ReadJsonAsync_OlderVersionWithoutConfigFile_ReturnsMigratedDoc TestContext.Current.CancellationToken); // Assert - result!.Document.Title + result + .Should().NotBeNull(); + + result.Document.Title .Should().Be("Login"); result.Warnings @@ -129,7 +142,10 @@ public async Task ReadJsonAsync_OlderVersionWithMigrateAndWarnOrMissingSetting_R TestContext.Current.CancellationToken); // Assert - result!.Document.Title + result + .Should().NotBeNull(); + + result.Document.Title .Should().Be("Login"); result.Warnings @@ -196,7 +212,10 @@ public async Task ReadJsonAsync_OlderVersionWithUpgradeAccess_MigratesWithWarnin FormatAccess.Upgrade, TestContext.Current.CancellationToken); // Assert - result!.Document.Title + result + .Should().NotBeNull(); + + result.Document.Title .Should().Be("Login"); result.Warnings @@ -216,7 +235,10 @@ public async Task ReadJsonAsync_OutdatedConfigWithStopAndUpgradeAccess_MigratesC TestContext.Current.CancellationToken); // Assert - result!.Warnings + result + .Should().NotBeNull(); + + result.Warnings .Should().Equal(new FormatWarning(FormatWarningKind.UpgradeNeeded, ConfigDisplayPath, 1, 2)); } @@ -271,7 +293,10 @@ public async Task ReadJsonAsync_OlderVersionAndOutdatedConfigWithMigrateAndWarn_ TestContext.Current.CancellationToken); // Assert - result!.Warnings + result + .Should().NotBeNull(); + + result.Warnings .Should().Equal( new FormatWarning(FormatWarningKind.UpgradeNeeded, SampleDisplayPath, 1, 2), new FormatWarning(FormatWarningKind.UpgradeNeeded, ConfigDisplayPath, 1, 2)); @@ -339,7 +364,10 @@ public async Task ReadJsonAsync_OutdatedConfigItselfWithMigrateAndWarn_ReturnsOn TestContext.Current.CancellationToken); // Assert - result!.Warnings + result + .Should().NotBeNull(); + + result.Warnings .Should().Equal(new FormatWarning(FormatWarningKind.UpgradeNeeded, ConfigDisplayPath, 1, 2)); } @@ -493,15 +521,33 @@ await kitStore.WriteJsonAsync(Path.Combine("features", "login", SampleFileName), bytes.Take(3) .Should().NotEqual(new byte[] { 0xEF, 0xBB, 0xBF }); - var root = JsonNode.Parse(bytes)!; + var root = JsonNode.Parse(bytes); + + root + .Should().NotBeNull(); + + var schemaVersionNode = root["schemaVersion"]; + + schemaVersionNode + .Should().NotBeNull(); - root["schemaVersion"]!.GetValue() + schemaVersionNode.GetValue() .Should().Be(2); - root["minEngineVersion"]!.GetValue() + var minEngineVersionNode = root["minEngineVersion"]; + + minEngineVersionNode + .Should().NotBeNull(); + + minEngineVersionNode.GetValue() .Should().Be("0.2.0"); - root["title"]!.GetValue() + var titleNode = root["title"]; + + titleNode + .Should().NotBeNull(); + + titleNode.GetValue() .Should().Be("Grüße"); } @@ -556,7 +602,17 @@ public async Task WriteJsonAsync_ExistingCurrentFile_ReplacesIt() FormatAccess.Write, TestContext.Current.CancellationToken); // Assert - JsonNode.Parse(tempDirectory.ReadFileText(".shadekit", SampleFileName))!["title"]!.GetValue() + var root = JsonNode.Parse(tempDirectory.ReadFileText(".shadekit", SampleFileName)); + + root + .Should().NotBeNull(); + + var titleNode = root["title"]; + + titleNode + .Should().NotBeNull(); + + titleNode.GetValue() .Should().Be("Logout"); } @@ -600,16 +656,34 @@ await kitStore.WriteJsonAsync(SampleFileName, SampleFormat.Id, result!.Document, TestContext.Current.CancellationToken); // Assert - var root = JsonNode.Parse(tempDirectory.ReadFileText(".shadekit", SampleFileName))!; + var root = JsonNode.Parse(tempDirectory.ReadFileText(".shadekit", SampleFileName)); + + root + .Should().NotBeNull(); + + var schemaVersionNode = root["schemaVersion"]; - root["schemaVersion"]!.GetValue() + schemaVersionNode + .Should().NotBeNull(); + + schemaVersionNode.GetValue() .Should().Be(2); - root["title"]!.GetValue() + var titleNode = root["title"]; + + titleNode + .Should().NotBeNull(); + + titleNode.GetValue() .Should().Be("Login"); - (await kitStore.ReadJsonAsync(SampleFileName, SampleFormat.Id, FormatAccess.Write, - TestContext.Current.CancellationToken))!.Warnings + var rereadResult = await kitStore.ReadJsonAsync(SampleFileName, SampleFormat.Id, + FormatAccess.Write, TestContext.Current.CancellationToken); + + rereadResult + .Should().NotBeNull(); + + rereadResult.Warnings .Should().BeEmpty(); } @@ -840,7 +914,10 @@ public async Task ReadJsonAsync_CurrentVersionAndOutdatedConfigWithStop_ReturnsR TestContext.Current.CancellationToken); // Assert - result!.Warnings + result + .Should().NotBeNull(); + + result.Warnings .Should().BeEmpty(); } @@ -875,7 +952,10 @@ public async Task ReadJsonAsync_OutdatedConfigItselfByOtherSpelling_ReturnsOneWa TestContext.Current.CancellationToken); // Assert - result!.Warnings + result + .Should().NotBeNull(); + + result.Warnings .Should().Equal(new FormatWarning(FormatWarningKind.UpgradeNeeded, ConfigDisplayPath, 1, 2)); } @@ -895,7 +975,10 @@ public async Task ReadJsonAsync_OutdatedConfigItselfByOtherCaseOnCaseInsensitive TestContext.Current.CancellationToken); // Assert - result!.Warnings + result + .Should().NotBeNull(); + + result.Warnings .Should().ContainSingle(); } diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreTests.cs index 303b7d3..74afa3e 100644 --- a/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreTests.cs +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Store/KitStoreTests.cs @@ -1,6 +1,7 @@ using AwesomeAssertions; using CreativeCoders.ShadeKit.Core.Formats; using CreativeCoders.ShadeKit.Core.Store; +using CreativeCoders.ShadeKit.Tests.Shared; namespace CreativeCoders.ShadeKit.Core.Tests.Store; @@ -60,7 +61,7 @@ public void GetFeatureNames_FeaturesDirectoryEmpty_ReturnsEmpty() } [Fact] - public void GetFeatureNames_FeaturesDirectoryMissing_ThrowsFeaturesDirectoryNotFoundException() + public void GetFeatureNames_FeaturesDirectoryMissing_ReturnsEmpty() { // Arrange using var tempDirectory = new TempKitDirectory(); @@ -68,15 +69,15 @@ public void GetFeatureNames_FeaturesDirectoryMissing_ThrowsFeaturesDirectoryNotF var kitStore = CreateStore(tempDirectory.RootPath); // Act - var act = () => kitStore.GetFeatureNames(); + var featureNames = kitStore.GetFeatureNames(); // Assert - act.Should().Throw() - .Which.FeaturesPath.Should().Be(Path.Combine(tempDirectory.RootPath, "features")); + featureNames + .Should().BeEmpty(); } [Fact] - public void GetFeatureNames_FeaturesIsAFile_ThrowsFeaturesDirectoryNotFoundException() + public void GetFeatureNames_FeaturesIsAFile_ReturnsEmpty() { // Arrange using var tempDirectory = new TempKitDirectory(); @@ -85,15 +86,15 @@ public void GetFeatureNames_FeaturesIsAFile_ThrowsFeaturesDirectoryNotFoundExcep var kitStore = CreateStore(tempDirectory.RootPath); // Act - var act = () => kitStore.GetFeatureNames(); + var featureNames = kitStore.GetFeatureNames(); // Assert - act.Should().Throw() - .Which.FeaturesPath.Should().Be(Path.Combine(tempDirectory.RootPath, "features")); + featureNames + .Should().BeEmpty(); } [Fact] - public void GetFeatureNames_StorePathDoesNotExist_ThrowsFeaturesDirectoryNotFoundException() + public void GetFeatureNames_StorePathDoesNotExist_ReturnsEmptyWithoutCreatingDirectories() { // Arrange using var tempDirectory = new TempKitDirectory(); @@ -102,16 +103,14 @@ public void GetFeatureNames_StorePathDoesNotExist_ThrowsFeaturesDirectoryNotFoun var kitStore = CreateStore(missingStorePath); // Act - var act = () => kitStore.GetFeatureNames(); + var featureNames = kitStore.GetFeatureNames(); // Assert - var exception = act.Should().Throw().Which; - - exception.FeaturesPath - .Should().Be(Path.Combine(missingStorePath, "features")); + featureNames + .Should().BeEmpty(); - exception.Message - .Should().Contain(exception.FeaturesPath); + Path.Exists(missingStorePath) + .Should().BeFalse(); } [Theory] @@ -179,6 +178,108 @@ public void RootPath_PathWithParentSegments_ReturnsNormalizedFullPath() .Should().Be(kitStorePath); } + [Fact] + public async Task WriteFileAsync_NestedPathInMissingStore_CreatesDirectoriesAndWritesBytesUnchanged() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var kitStore = CreateStore(Path.Combine(tempDirectory.RootPath, ".shadekit")); + byte[] content = [0x23, 0x20, 0xC3, 0xA4, 0x0D, 0x0A]; + + // Act + await kitStore.WriteFileAsync("templates/checks/ready.md", content, TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileBytes(".shadekit", "templates", "checks", "ready.md") + .Should().Equal(content); + } + + [Fact] + public async Task WriteFileAsync_ExistingFile_ReplacesIt() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + tempDirectory.WriteFile("old content", "templates", "spec.md"); + var kitStore = CreateStore(tempDirectory.RootPath); + + // Act + await kitStore.WriteFileAsync("templates/spec.md", "new"u8.ToArray(), TestContext.Current.CancellationToken); + + // Assert + tempDirectory.ReadFileText("templates", "spec.md") + .Should().Be("new"); + } + + [Theory] + [InlineData("../outside.md")] + [InlineData("templates/../../outside.md")] + public async Task WriteFileAsync_PathOutsideStore_ThrowsArgumentExceptionAndWritesNothing(string relativePath) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var kitStore = CreateStore(Path.Combine(tempDirectory.RootPath, ".shadekit")); + + // Act + var act = () => kitStore.WriteFileAsync(relativePath, [0x41], TestContext.Current.CancellationToken); + + // Assert + (await act.Should().ThrowAsync()) + .WithParameterName(nameof(relativePath)); + + Directory.EnumerateFileSystemEntries(tempDirectory.RootPath) + .Should().BeEmpty(); + } + + [Fact] + public async Task WriteFileAsync_NullContent_ThrowsArgumentNullException() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var kitStore = CreateStore(tempDirectory.RootPath); + + // Act + var act = () => kitStore.WriteFileAsync("spec.md", null!, TestContext.Current.CancellationToken); + + // Assert + (await act.Should().ThrowAsync()) + .WithParameterName("content"); + } + + [Fact] + public void CreateDirectory_PathInMissingStore_CreatesStoreAndDirectory() + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var kitStore = CreateStore(Path.Combine(tempDirectory.RootPath, ".shadekit")); + + // Act + kitStore.CreateDirectory(KitStorePaths.FeaturesDirName); + + // Assert + Directory.Exists(Path.Combine(tempDirectory.RootPath, ".shadekit", "features")) + .Should().BeTrue(); + } + + [Theory] + [InlineData("..")] + [InlineData("../sibling")] + public void CreateDirectory_PathOutsideStore_ThrowsArgumentExceptionAndCreatesNothing(string relativePath) + { + // Arrange + using var tempDirectory = new TempKitDirectory(); + var kitStore = CreateStore(Path.Combine(tempDirectory.RootPath, ".shadekit")); + + // Act + var act = () => kitStore.CreateDirectory(relativePath); + + // Assert + act.Should().Throw() + .WithParameterName(nameof(relativePath)); + + Directory.EnumerateFileSystemEntries(tempDirectory.RootPath) + .Should().BeEmpty(); + } + private static KitStore CreateStore(string kitStorePath) { var formatRegistry = FormatRegistry.CreateDefault(); diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Templates/ShippedTemplateReaderTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Templates/ShippedTemplateReaderTests.cs new file mode 100644 index 0000000..0949eb3 --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Templates/ShippedTemplateReaderTests.cs @@ -0,0 +1,214 @@ +using System.Text; +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Templates; + +namespace CreativeCoders.ShadeKit.Core.Tests.Templates; + +public class ShippedTemplateReaderTests +{ + private static readonly ShippedTemplate SampleTemplate = + new("checks/sample.template.md", "templates/checks/sample.md"); + + public static TheoryData ShippedResourcePaths => + new(ShippedTemplates.All.Select(x => x.ResourcePath)); + + [Theory] + [MemberData(nameof(ShippedResourcePaths))] + public void ReadContent_ShippedTemplate_ReturnsMarkdownContentWithLfLineEndings(string resourcePath) + { + // Arrange + var template = ShippedTemplates.All.Single(x => x.ResourcePath == resourcePath); + var reader = new ShippedTemplateReader(); + + // Act + var content = reader.ReadContent(template); + + // Assert + content + .Should().StartWith("# ") + .And.NotContain("\r\n") + .And.NotStartWith(""); + } + + [Fact] + public void ReadContent_ResourceWithCrlfLineEndings_ReturnsContentWithLfLineEndings() + { + // Arrange + var reader = new ShippedTemplateReader(_ => CreateStream("# Title\r\n\r\nLine one\r\nLine two\r\n")); + + // Act + var content = reader.ReadContent(SampleTemplate); + + // Assert + content + .Should().Be("# Title\n\nLine one\nLine two\n"); + } + + [Fact] + public void ReadContent_ResourceWithMixedLineEndings_ReturnsContentWithoutCrlf() + { + // Arrange + var reader = new ShippedTemplateReader(_ => CreateStream("one\r\ntwo\nthree\r\n")); + + // Act + var content = reader.ReadContent(SampleTemplate); + + // Assert + content + .Should().Be("one\ntwo\nthree\n"); + } + + [Theory] + [InlineData("one\r\r\ntwo", "one\n\ntwo")] + [InlineData("one\rtwo\r", "one\ntwo\n")] + [InlineData("\r\n\r\n", "\n\n")] + public void ReadContent_ResourceWithCarriageReturns_ReturnsContentWithoutCrlf(string resourceContent, + string expectedContent) + { + // Arrange + var reader = new ShippedTemplateReader(_ => CreateStream(resourceContent)); + + // Act + var content = reader.ReadContent(SampleTemplate); + + // Assert + content + .Should().Be(expectedContent) + .And.NotContain("\r\n"); + } + + [Fact] + public void ReadContent_EmptyResource_ReturnsEmptyString() + { + // Arrange + var reader = new ShippedTemplateReader(_ => CreateStream(string.Empty)); + + // Act + var content = reader.ReadContent(SampleTemplate); + + // Assert + content + .Should().BeEmpty(); + } + + [Fact] + public void ReadContent_ResourceWithUtf8Bom_ReturnsContentWithoutBom() + { + // Arrange + var bytes = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes("# Title\n")).ToArray(); + var reader = new ShippedTemplateReader(_ => new MemoryStream(bytes)); + + // Act + var content = reader.ReadContent(SampleTemplate); + + // Assert + content + .Should().Be("# Title\n"); + } + + [Fact] + public void ReadContent_ResourceWithNonAsciiCharacters_ReturnsContentDecodedAsUtf8() + { + // Arrange + var reader = new ShippedTemplateReader(_ => CreateStream("Feature: `{{feature-id}}` · Spec – done\n")); + + // Act + var content = reader.ReadContent(SampleTemplate); + + // Assert + content + .Should().Be("Feature: `{{feature-id}}` · Spec – done\n"); + } + + [Fact] + public void ReadContent_Template_OpensResourceWithTemplatesPrefixAndForwardSlashes() + { + // Arrange + var openedNames = new List(); + var reader = new ShippedTemplateReader(name => + { + openedNames.Add(name); + return CreateStream("# Title\n"); + }); + + // Act + reader.ReadContent(SampleTemplate); + + // Assert + openedNames + .Should().Equal("templates/checks/sample.template.md"); + } + + [Fact] + public void ReadContent_Template_DisposesResourceStream() + { + // Arrange + var stream = CreateStream("# Title\n"); + var reader = new ShippedTemplateReader(_ => stream); + + // Act + reader.ReadContent(SampleTemplate); + + // Assert + stream.CanRead + .Should().BeFalse(); + } + + [Fact] + public void ReadContent_ResourceMissing_ThrowsInvalidOperationExceptionNamingResource() + { + // Arrange + var reader = new ShippedTemplateReader(_ => null); + + // Act + var act = () => reader.ReadContent(SampleTemplate); + + // Assert + act.Should().Throw() + .WithMessage("*'templates/checks/sample.template.md'*"); + } + + [Fact] + public void ReadContent_TemplateNotInCoreAssembly_ThrowsInvalidOperationException() + { + // Arrange + var reader = new ShippedTemplateReader(); + + // Act + var act = () => reader.ReadContent(SampleTemplate); + + // Assert + act.Should().Throw() + .WithMessage("*'templates/checks/sample.template.md'*"); + } + + [Fact] + public void ReadContent_NullTemplate_ThrowsArgumentNullException() + { + // Arrange + var reader = new ShippedTemplateReader(); + + // Act + var act = () => reader.ReadContent(null!); + + // Assert + act.Should().Throw() + .WithParameterName("shippedTemplate"); + } + + [Fact] + public void Ctor_NullOpenResource_ThrowsArgumentNullException() + { + // Act + var act = () => new ShippedTemplateReader(null!); + + // Assert + act.Should().Throw() + .WithParameterName("openResource"); + } + + private static MemoryStream CreateStream(string content) + { + return new MemoryStream(new UTF8Encoding(false).GetBytes(content)); + } +} diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Templates/ShippedTemplatesTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Templates/ShippedTemplatesTests.cs new file mode 100644 index 0000000..5425b87 --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Templates/ShippedTemplatesTests.cs @@ -0,0 +1,107 @@ +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Templates; + +namespace CreativeCoders.ShadeKit.Core.Tests.Templates; + +public class ShippedTemplatesTests +{ + [Fact] + public void All_EmbeddedTemplateResources_MatchCatalogOneToOne() + { + // Arrange + var embeddedTemplateResources = typeof(ShippedTemplate).Assembly + .GetManifestResourceNames() + .Where(name => name.StartsWith(ShippedTemplateReader.ResourceNamePrefix, StringComparison.Ordinal)) + .ToArray(); + + // Act + var catalogResources = ShippedTemplates.All + .Select(ShippedTemplateReader.GetResourceName) + .ToArray(); + + // Assert + catalogResources + .Should().OnlyHaveUniqueItems() + .And.BeEquivalentTo(embeddedTemplateResources); + } + + [Fact] + public void All_Catalog_MatchesTemplateTable() + { + // Arrange + (string ResourcePath, string RepositoryPath)[] templateTable = + [ + ("request.template.md", ".shadekit/templates/request.md"), + ("discussion.template.md", ".shadekit/templates/discussion.md"), + ("spec.template.md", ".shadekit/templates/spec.md"), + ("researches.template.md", ".shadekit/templates/researches.md"), + ("plan.template.md", ".shadekit/templates/plan.md"), + ("tasks.template.md", ".shadekit/templates/tasks.md"), + ("review.template.md", ".shadekit/templates/review.md"), + ("verification.template.md", ".shadekit/templates/verification.md"), + ("playbook.template.md", ".shadekit/templates/playbook.md"), + ("report.template.md", ".shadekit/templates/report.md"), + ("diagnosis.template.md", ".shadekit/templates/diagnosis.md"), + ("checks/definition-of-ready.template.md", ".shadekit/templates/checks/definition-of-ready.md"), + ("checks/definition-of-done.template.md", ".shadekit/templates/checks/definition-of-done.md") + ]; + + // Act + var catalog = ShippedTemplates.All.Select(x => (x.ResourcePath, x.RepositoryPath)); + + // Assert + catalog + .Should().Equal(templateTable); + } + + [Theory] + [InlineData("spec.template.md", ".shadekit/templates/spec.md")] + [InlineData("checks/definition-of-ready.template.md", ".shadekit/templates/checks/definition-of-ready.md")] + public void All_Fp02Ac8Examples_MapToRepositoryPathFromTemplateTable(string resourcePath, string repositoryPath) + { + // Act + var template = ShippedTemplates.All.Single(x => x.ResourcePath == resourcePath); + + // Assert + template.RepositoryPath + .Should().Be(repositoryPath); + } + + [Fact] + public void All_RepositoryPaths_AreUniqueIgnoringCase() + { + // Act + var repositoryPaths = ShippedTemplates.All.Select(x => x.RepositoryPath.ToUpperInvariant()); + + // Assert + repositoryPaths + .Should().OnlyHaveUniqueItems(); + } + + [Fact] + public void All_KitPaths_AreTheRepositoryPathsRelativeToTheKitRoot() + { + // Act + var templates = ShippedTemplates.All; + + // Assert + templates + .Should().AllSatisfy(template => template.RepositoryPath + .Should().Be(".shadekit/" + template.KitPath)); + } + + [Fact] + public void All_RepositoryPaths_AreRelativeUnderShadekitTemplatesWithForwardSlashes() + { + // Act + var repositoryPaths = ShippedTemplates.All.Select(x => x.RepositoryPath); + + // Assert + repositoryPaths + .Should().AllSatisfy(path => path + .Should().StartWith(".shadekit/templates/") + .And.EndWith(".md") + .And.NotContain("\\") + .And.NotContain(".template.")); + } +} diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Templates/TemplatesLockTests.cs b/tests/CreativeCoders.ShadeKit.Core.Tests/Templates/TemplatesLockTests.cs new file mode 100644 index 0000000..8f1859a --- /dev/null +++ b/tests/CreativeCoders.ShadeKit.Core.Tests/Templates/TemplatesLockTests.cs @@ -0,0 +1,224 @@ +using System.Text; +using System.Text.Json.Nodes; +using AwesomeAssertions; +using CreativeCoders.ShadeKit.Core.Formats; +using CreativeCoders.ShadeKit.Core.Templates; + +namespace CreativeCoders.ShadeKit.Core.Tests.Templates; + +public class TemplatesLockTests +{ + private const string FilePath = ".shadekit/templates.lock.json"; + + private const string SpecHash = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + private const string PlanHash = "sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + + [Fact] + public void Serialize_LockWithEntries_WritesFieldsInContractOrder() + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + var templatesLock = new TemplatesLock + { + Templates = + [ + new TemplatesLockEntry { Path = ".shadekit/templates/plan.md", KitVersion = "1.2.3", Hash = PlanHash }, + new TemplatesLockEntry { Path = ".shadekit/templates/spec.md", KitVersion = "1.2.3", Hash = SpecHash } + ] + }; + + // Act + var json = serializer.Serialize(templatesLock, FormatIds.TemplatesLock); + + // Assert + json + .Should().Be( + $$""" + { + "schemaVersion": 1, + "minEngineVersion": "0.1.0", + "templates": [ + { + "path": ".shadekit/templates/plan.md", + "kitVersion": "1.2.3", + "hash": "{{PlanHash}}" + }, + { + "path": ".shadekit/templates/spec.md", + "kitVersion": "1.2.3", + "hash": "{{SpecHash}}" + } + ] + } + + """.ReplaceLineEndings("\n")); + } + + [Fact] + public void Serialize_NewLock_WritesEmptyTemplates() + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var json = serializer.Serialize(new TemplatesLock(), FormatIds.TemplatesLock); + + // Assert + var root = JsonNode.Parse(json); + + root + .Should().NotBeNull(); + + var templatesNode = root["templates"]; + + templatesNode + .Should().NotBeNull(); + + templatesNode.AsArray() + .Should().BeEmpty(); + } + + [Fact] + public void Deserialize_LockWithEntries_ReadsEntries() + { + // Arrange + const string json = + $$""" + { + "schemaVersion": 1, + "minEngineVersion": "0.1.0", + "templates": [ + { "path": ".shadekit/templates/spec.md", "kitVersion": "1.2.3", "hash": "{{SpecHash}}" } + ] + } + """; + + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var templatesLock = serializer + .Deserialize(json, FormatIds.TemplatesLock, FilePath, false).Document; + + // Assert + templatesLock.Templates + .Should().ContainSingle() + .Which.Should().BeEquivalentTo(new TemplatesLockEntry + { + Path = ".shadekit/templates/spec.md", + KitVersion = "1.2.3", + Hash = SpecHash + }); + } + + [Fact] + public void Deserialize_TemplatesMissing_ReturnsEmptyList() + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var templatesLock = serializer.Deserialize( + """{ "schemaVersion": 1, "minEngineVersion": "0.1.0" }""", FormatIds.TemplatesLock, FilePath, + false).Document; + + // Assert + templatesLock.Templates + .Should().BeEmpty(); + } + + [Fact] + [Trait("Requirement", "FP01-AC3")] + public void SerializeAfterDeserialize_LockWithUnknownFields_KeepsThemAtTopLevelAndInEntries() + { + // Arrange + const string original = + $$""" + { + "schemaVersion": 1, + "minEngineVersion": "0.1.0", + "templates": [ + { + "path": ".shadekit/templates/spec.md", + "kitVersion": "1.2.3", + "hash": "{{SpecHash}}", + "owner": { "package": "10", "size": 1.50 } + } + ], + "generatedBy": [ "init", null, 2.0 ] + } + """; + + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var templatesLock = serializer + .Deserialize(original, FormatIds.TemplatesLock, FilePath, false).Document; + var json = serializer.Serialize(templatesLock, FormatIds.TemplatesLock); + + // Assert + JsonNode.DeepEquals(JsonNode.Parse(json), JsonNode.Parse(original)) + .Should().BeTrue($"the written lock file must keep every field, but was:{Environment.NewLine}{json}"); + + templatesLock.Templates.Single().ExtensionData + .Should().ContainKey("owner"); + + json.Should().Contain("\"size\": 1.50"); + } + + [Theory] + [InlineData(""" "kitVersion": "1.2.3", "hash": "sha256:00" """)] + [InlineData(""" "path": null, "kitVersion": "1.2.3", "hash": "sha256:00" """)] + [InlineData(""" "path": ".shadekit/templates/spec.md", "kitVersion": "1.2.3" """)] + [InlineData(""" "path": ".shadekit/templates/spec.md", "kitVersion": "1.2.3", "hash": null """)] + [InlineData(""" "path": ".shadekit/templates/spec.md", "hash": "sha256:00" """)] + [InlineData(""" "path": ".shadekit/templates/spec.md", "kitVersion": null, "hash": "sha256:00" """)] + [InlineData(""" "path": 1, "kitVersion": "1.2.3", "hash": "sha256:00" """)] + [InlineData(""" "path": ".shadekit/templates/spec.md", "kitVersion": 1, "hash": "sha256:00" """)] + [InlineData(""" "path": ".shadekit/templates/spec.md", "kitVersion": "1.2.3", "hash": true """)] + public void Deserialize_EntryWithMissingOrInvalidRequiredField_ThrowsInvalidKitFileException(string entryFields) + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var act = () => serializer.Deserialize( + $$"""{ "schemaVersion": 1, "minEngineVersion": "0.1.0", "templates": [ { {{entryFields}} } ] }""", + FormatIds.TemplatesLock, FilePath, false); + + // Assert + act.Should().Throw(); + } + + [Theory] + [InlineData("null")] + [InlineData("{}")] + [InlineData("[ null ]")] + [InlineData("[ \".shadekit/templates/spec.md\" ]")] + public void Deserialize_InvalidTemplates_ThrowsInvalidKitFileException(string templates) + { + // Arrange + var serializer = new VersionedJsonSerializer(FormatRegistry.CreateDefault()); + + // Act + var act = () => serializer.Deserialize( + $$"""{ "schemaVersion": 1, "minEngineVersion": "0.1.0", "templates": {{templates}} }""", + FormatIds.TemplatesLock, FilePath, false); + + // Assert + act.Should().Throw(); + } + + [Theory] + [InlineData("", "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")] + [InlineData("abc", "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")] + public void ComputeHash_Content_ReturnsPrefixedLowercaseSha256(string content, string expectedHash) + { + // Act + var hash = TemplatesLockEntry.ComputeHash(Encoding.UTF8.GetBytes(content)); + + // Assert + hash + .Should().Be(expectedHash); + } +} diff --git a/tests/Shared/CurrentDirectoryScope.cs b/tests/Shared/CurrentDirectoryScope.cs new file mode 100644 index 0000000..bb82519 --- /dev/null +++ b/tests/Shared/CurrentDirectoryScope.cs @@ -0,0 +1,35 @@ +namespace CreativeCoders.ShadeKit.Tests.Shared; + +/// +/// Restores the current directory of the process on disposal, so that a test can change it. +/// +/// +/// The current directory is process-wide; tests that change it must run in a collection that is not run in +/// parallel with other tests. +/// +internal sealed class CurrentDirectoryScope : IDisposable +{ + private readonly string _originalDirectory = Directory.GetCurrentDirectory(); + + /// + /// Initializes a new instance of the class that keeps the current directory. + /// + public CurrentDirectoryScope() + { + } + + /// + /// Initializes a new instance of the class and makes the specified directory + /// the current directory. + /// + /// The directory to make the current directory. + public CurrentDirectoryScope(string path) + { + Directory.SetCurrentDirectory(path); + } + + public void Dispose() + { + Directory.SetCurrentDirectory(_originalDirectory); + } +} diff --git a/tests/CreativeCoders.ShadeKit.Core.Tests/Store/TempKitDirectory.cs b/tests/Shared/TempKitDirectory.cs similarity index 52% rename from tests/CreativeCoders.ShadeKit.Core.Tests/Store/TempKitDirectory.cs rename to tests/Shared/TempKitDirectory.cs index 8e4b63c..7bb1426 100644 --- a/tests/CreativeCoders.ShadeKit.Core.Tests/Store/TempKitDirectory.cs +++ b/tests/Shared/TempKitDirectory.cs @@ -1,11 +1,12 @@ -namespace CreativeCoders.ShadeKit.Core.Tests.Store; +namespace CreativeCoders.ShadeKit.Tests.Shared; /// -/// Creates a temporary directory tree for SHADE kit store tests and deletes it on disposal. +/// Creates a temporary directory tree for SHADE kit tests and deletes it on disposal. /// -internal sealed class TempKitDirectory : IDisposable +/// The prefix of the name of the temporary directory. +internal sealed class TempKitDirectory(string prefix = "shade-kit-tests-") : IDisposable { - private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("shade-kit-tests-"); + private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory(prefix); public string RootPath => _directory.FullName; @@ -44,6 +45,16 @@ public string WriteFile(string content, params string[] relativePathParts) return path; } + public string WriteFileBytes(byte[] content, params string[] relativePathParts) + { + var path = Path.Combine([RootPath, .. relativePathParts]); + + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, content); + + return path; + } + public byte[] ReadFileBytes(params string[] relativePathParts) { return File.ReadAllBytes(Path.Combine([RootPath, .. relativePathParts])); @@ -64,6 +75,40 @@ public void SkipWhenGitRootExistsAbove() $"A '.git' entry exists above '{RootPath}'; the not-in-a-repository scenario cannot be tested here."); } + /// + /// Creates a symbolic link in the temporary directory, or skips the current test if the file system or the + /// process cannot create one, for example on Windows without the privilege to create symbolic links. + /// + /// The target of the link, relative to the directory of the link. + /// The parts of the link path, relative to the temporary directory. + /// The full path of the link. + public string CreateSymbolicLinkOrSkip(string linkTarget, params string[] relativePathParts) + { + var linkPath = Path.Combine([RootPath, .. relativePathParts]); + + try + { + File.CreateSymbolicLink(linkPath, linkTarget); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Assert.Skip($"A symbolic link cannot be created at '{linkPath}': {exception.Message}"); + } + + return linkPath; + } + + /// + /// Captures every directory and the bytes of every file below the temporary directory, keyed by relative path. + /// + public Dictionary TakeSnapshot() + { + return Directory.EnumerateFileSystemEntries(RootPath, "*", SearchOption.AllDirectories) + .ToDictionary( + x => Path.GetRelativePath(RootPath, x), + x => Directory.Exists(x) ? "" : Convert.ToBase64String(File.ReadAllBytes(x))); + } + private bool HasGitRootAbove() { for (var directory = _directory.Parent; directory is not null; directory = directory.Parent) diff --git a/tests/Shared/TestText.cs b/tests/Shared/TestText.cs new file mode 100644 index 0000000..548b08f --- /dev/null +++ b/tests/Shared/TestText.cs @@ -0,0 +1,20 @@ +namespace CreativeCoders.ShadeKit.Tests.Shared; + +/// +/// Provides helpers for tests that check the bytes and lines of text files. +/// +internal static class TestText +{ + /// + /// Gets the UTF-8 byte order mark. + /// + public static byte[] Utf8Bom { get; } = [0xEF, 0xBB, 0xBF]; + + /// + /// Counts the lines of a text, separated by \n, whose trimmed text starts with a prefix. + /// + public static int CountLinesStartingWith(string text, string prefix) + { + return text.Split('\n').Count(x => x.Trim().StartsWith(prefix, StringComparison.Ordinal)); + } +}