diff --git a/.kiro/specs/custom-template-packs/.config.kiro b/.kiro/specs/custom-template-packs/.config.kiro
new file mode 100644
index 0000000..b871312
--- /dev/null
+++ b/.kiro/specs/custom-template-packs/.config.kiro
@@ -0,0 +1 @@
+{"specId": "5b110807-2f47-49e3-9fed-9967cc499c4b", "workflowType": "requirements-first", "specType": "feature"}
diff --git a/.kiro/specs/custom-template-packs/design.md b/.kiro/specs/custom-template-packs/design.md
new file mode 100644
index 0000000..2834fb5
--- /dev/null
+++ b/.kiro/specs/custom-template-packs/design.md
@@ -0,0 +1,805 @@
+# Design Document: Custom Template Packs and Rules Packs
+
+## Overview
+
+This feature extends Steergen with two pack-based extensibility mechanisms and retires the legacy `globalRoot` configuration:
+
+1. **Template Packs** — User-provided Scriban templates that override built-in rendering for specific targets, or provide complete target definitions for new external targets. Sourced from local directories or GitHub repositories.
+2. **Rules Packs** — Shared governance rule sets published to GitHub repositories, loaded and merged alongside project-local rules with scope-based precedence.
+3. **globalRoot Retirement** — The legacy `globalRoot` configuration field is removed; its use case is fully replaced by rules packs with `scope: global`.
+
+The design preserves all existing architectural invariants: no dynamic plugin loading, deterministic outputs, additive-only changes, and clear separation between parsing → model → validation → routing → rendering.
+
+### Design Rationale
+
+The current `globalRoot` mechanism is a filesystem path coupling that doesn't support versioning, sharing across teams, or scope-based precedence. Template packs address the need for output customisation without forking the tool. Rules packs address the need for shared governance without manual file copying. Both use the same GitHub-based distribution and local caching pattern, keeping the mental model consistent.
+
+The external target pack model provides the extensibility path for new targets without dynamic plugin loading. A pack author provides templates and a default layout YAML; Steergen supplies a generic `PackTargetComponent` that delegates rendering to the pack's templates. This keeps the binary stable while allowing the target ecosystem to grow externally.
+
+## Architecture
+
+### Component Topology
+
+```mermaid
+graph TD
+ CLI[Steergen CLI] --> Config[SteergenConfigLoader]
+ Config --> PackDownloader[PackDownloader]
+ PackDownloader --> GitHubClient[GitHub REST Client]
+ PackDownloader --> LocalCache[Local Pack Cache]
+
+ CLI --> Pipeline[GenerationPipeline]
+ Pipeline --> RulesPackLoader[RulesPackLoader]
+ RulesPackLoader --> LocalCache
+ RulesPackLoader --> Parser[SteeringMarkdownParser]
+ RulesPackLoader --> Validator[SteeringValidator]
+ RulesPackLoader --> Resolver[SteeringResolver]
+
+ Pipeline --> TemplateResolver[TemplateResolver]
+ TemplateResolver --> LocalOverride[Local Override Path]
+ TemplateResolver --> CachedPack[Cached GitHub Pack]
+ TemplateResolver --> Embedded[EmbeddedTemplateProvider]
+
+ Pipeline --> RoutePlanner
+ RoutePlanner --> WritePlanBuilder
+ WritePlanBuilder --> TargetComponent[ITargetComponent]
+ TargetComponent --> TemplateResolver
+```
+
+### Pipeline Integration Points
+
+The feature integrates at four points in the existing pipeline:
+
+1. **Configuration loading** (step 1): Extended `SteeringConfiguration` model with `templatePack` and `rulesPacks` sections. Detection of deprecated `globalRoot` with error diagnostic.
+2. **Target registration** (step 1): External targets declared in template pack `providedTargets` are registered alongside built-in targets, using a generic `PackTargetComponent`.
+3. **Document discovery and merge** (steps 2–4): `RulesPackLoader` discovers, parses, validates, and feeds rules pack documents into `SteeringResolver` with scope metadata.
+4. **Target rendering** (step 10): `TemplateResolver` replaces direct `EmbeddedTemplateProvider` usage, implementing the three-level override precedence chain with target-scoped filtering.
+
+### Boundary Preservation
+
+- The write plan remains the boundary between routing and rendering — packs do not affect routing logic.
+- Template packs only affect the rendering step; they do not introduce new routing semantics.
+- External target packs provide templates and a default layout but use the same routing and write-plan pipeline as built-in targets.
+- Rules packs feed into the existing merge step; they do not bypass validation or introduce new rule semantics.
+- No new runtime file dependencies are introduced for the default (no-pack) configuration.
+- No dynamic plugin loading: external targets use a generic `PackTargetComponent` compiled into the binary, not dynamically loaded assemblies.
+
+## Components and Interfaces
+
+### TemplateResolver
+
+Replaces direct `EmbeddedTemplateProvider` usage in target components. Implements `ITemplateProvider` with a three-level override chain and target-scoped filtering.
+
+```csharp
+namespace Steergen.Core.Targets;
+
+///
+/// Resolves Scriban templates using a three-level override precedence:
+/// 1. Local override path (templatePackPath in config)
+/// 2. Cached GitHub pack (downloaded to local pack cache)
+/// 3. Built-in embedded templates (EmbeddedTemplateProvider)
+///
+/// Template packs that declare a `targets` list are only consulted for
+/// those declared targets. Packs without a `targets` list apply to all targets.
+///
+public sealed class TemplateResolver : ITemplateProvider
+{
+ private readonly string? _localOverridePath;
+ private readonly string? _cachedPackPath;
+ private readonly ITemplateProvider _embeddedProvider;
+ private readonly IReadOnlySet? _declaredTargets;
+ private readonly long _maxFileSizeBytes;
+
+ public TemplateResolver(
+ string? localOverridePath,
+ string? cachedPackPath,
+ ITemplateProvider embeddedProvider,
+ IReadOnlySet? declaredTargets = null,
+ long maxFileSizeBytes = 1_048_576)
+ { }
+
+ public string GetTemplate(string targetId, string templateName)
+ { }
+
+ ///
+ /// Returns the source layer that would provide the template.
+ /// Used by `steergen inspect --templates`.
+ ///
+ public TemplateSource GetTemplateSource(string targetId, string templateName)
+ { }
+
+ ///
+ /// Returns true if this resolver can provide templates for the given target.
+ /// A resolver with no declared targets can provide for any target.
+ ///
+ public bool ProvidesForTarget(string targetId)
+ { }
+}
+
+public enum TemplateSource
+{
+ LocalOverride,
+ CachedGitHubPack,
+ BuiltInEmbedded,
+ ProvidedTarget // Template from an external target pack
+}
+```
+
+Resolution algorithm:
+1. Compute path: `{layer}/{targetId}/{templateName}.scriban`
+2. Check if the target is in the pack's declared `targets` list (if list is present)
+3. Check local override path (if configured and directory exists)
+4. Check cached GitHub pack path (if configured and downloaded)
+5. Fall back to `EmbeddedTemplateProvider`
+
+Target-scoped filtering:
+- If `declaredTargets` is non-null, the resolver only serves templates for those targets at the local/cached layers
+- If `declaredTargets` is null, the resolver serves templates for all targets (backward-compatible)
+- Template files found under undeclared target directories emit a diagnostic warning
+
+Constraints:
+- Does NOT follow symbolic links (uses `FileAttributes` check before reading)
+- Rejects files > 1 MB
+- Uses ordinal file path comparison for deterministic enumeration
+- Makes zero network requests
+
+### PackManifest
+
+Shared model for both template pack and rules pack manifests.
+
+```csharp
+namespace Steergen.Core.Packs;
+
+public sealed record PackManifest
+{
+ public required string Name { get; init; }
+ public required string Version { get; init; }
+ public required string MinSteergenVersion { get; init; }
+ public PackScope? Scope { get; init; } // Required for rules packs
+ public IReadOnlyList? Targets { get; init; } // Override targets (template packs)
+ public IReadOnlyList? ProvidedTargets { get; init; } // External targets
+ public string? RulesRoot { get; init; } // Optional, rules packs only
+}
+
+///
+/// Declares a complete target definition provided by a template pack.
+/// The pack supplies templates and a default layout; Steergen supplies
+/// the generic PackTargetComponent that delegates rendering.
+///
+public sealed record ProvidedTargetDefinition
+{
+ public required string TargetId { get; init; }
+ public required string DefaultLayout { get; init; } // Relative path to layout YAML within pack
+ public string? Description { get; init; }
+}
+
+public enum PackScope
+{
+ Global,
+ Supplemental,
+ Project
+}
+```
+
+### PackManifestParser
+
+```csharp
+namespace Steergen.Core.Packs;
+
+public sealed class PackManifestParser
+{
+ ///
+ /// Parses pack.yaml from the given directory.
+ /// Returns null if pack.yaml does not exist.
+ ///
+ public PackManifest? Parse(string packDirectory);
+
+ ///
+ /// Validates manifest fields. Returns diagnostics for missing/invalid fields.
+ ///
+ public IReadOnlyList Validate(
+ PackManifest manifest,
+ PackType packType,
+ string runningSteergenVersion);
+}
+
+public enum PackType
+{
+ Template,
+ Rules
+}
+```
+
+### PackDownloader
+
+Handles GitHub archive download and extraction to local cache.
+
+```csharp
+namespace Steergen.Core.Packs;
+
+public sealed class PackDownloader
+{
+ private readonly HttpClient _httpClient;
+ private readonly string _cacheBaseDirectory;
+
+ public PackDownloader(HttpClient httpClient, string cacheBaseDirectory)
+ { }
+
+ ///
+ /// Downloads a pack from GitHub to the local cache.
+ /// Returns the local cache path on success.
+ ///
+ public async Task DownloadAsync(
+ GitHubPackSource source,
+ PackType packType,
+ bool force,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns the local cache path for a given source, or null if not cached.
+ ///
+ public string? GetCachedPath(GitHubPackSource source, PackType packType);
+
+ ///
+ /// Determines if a ref is an immutable pin (40-char lowercase hex SHA).
+ ///
+ public static bool IsImmutablePin(string? refValue);
+}
+
+public sealed record GitHubPackSource
+{
+ public required string Owner { get; init; }
+ public required string Repo { get; init; }
+ public string? Ref { get; init; }
+ public string? Path { get; init; } // Subdirectory within repo
+}
+
+public sealed record PackDownloadResult
+{
+ public bool Success { get; init; }
+ public string? CachePath { get; init; }
+ public IReadOnlyList Diagnostics { get; init; } = [];
+}
+```
+
+Security constraints in `DownloadAsync`:
+- Validates no path traversal (`../`) in archive entry paths
+- Rejects entries outside the expected directory structure
+- Validates `pack.yaml` presence before committing to cache
+- Downloads via unauthenticated public archive URL (`https://github.com/{owner}/{repo}/archive/{ref}.tar.gz`) — no API tokens required
+- Only public GitHub repositories are supported; private repositories are out of scope
+- Uses atomic replacement: extracts to a temp directory, validates manifest, then swaps into cache location — existing cache is preserved on download failure
+
+### RulesPackLoader
+
+Discovers, parses, validates, and prepares rules pack documents for merge.
+
+```csharp
+namespace Steergen.Core.Packs;
+
+public sealed class RulesPackLoader
+{
+ private readonly PackManifestParser _manifestParser;
+ private readonly SteeringMarkdownParser _parser;
+ private readonly SteeringValidator _validator;
+
+ ///
+ /// Loads all rules from configured packs, applying scope and ordering.
+ /// Returns documents tagged with source pack metadata.
+ ///
+ public RulesPackLoadResult Load(
+ IReadOnlyList packConfigs,
+ string cacheBaseDirectory,
+ string runningSteergenVersion);
+}
+
+public sealed record RulesPackLoadResult
+{
+ public IReadOnlyList Documents { get; init; } = [];
+ public IReadOnlyList Diagnostics { get; init; } = [];
+}
+
+public sealed record RulesPackConfiguration
+{
+ public required GitHubPackSource Source { get; init; }
+ public PackScope? ScopeOverride { get; init; }
+}
+```
+
+Loading algorithm:
+1. For each configured pack (in declaration order):
+ a. Resolve cache path from `{cacheBase}/rules/{owner}/{repo}/{ref}/`
+ b. If cache missing → emit error diagnostic, skip pack
+ c. Parse `pack.yaml` → validate manifest (minSteergenVersion, required fields)
+ d. Determine effective scope: `ScopeOverride ?? manifest.Scope`
+ e. Resolve rules root: `manifest.RulesRoot ?? pack root`
+ f. Enumerate `*.md` files recursively (ordinal sort, no symlink follow)
+ g. Reject files > 1 MB
+ h. Parse each file with `SteeringMarkdownParser`
+ i. Validate with `SteeringValidator`
+ j. Tag each rule with `SourcePackName` and effective scope
+2. Return all documents grouped by effective scope
+
+### Extended SteeringResolver
+
+The existing `SteeringResolver.Resolve` method signature is extended to accept rules pack documents with scope metadata:
+
+```csharp
+public ResolvedSteeringModel Resolve(
+ IEnumerable projectDocuments,
+ IEnumerable packDocuments,
+ IEnumerable activeProfiles)
+```
+
+Where `ScopedPackDocuments` groups documents by effective scope. Merge precedence:
+1. Project-local rules (highest)
+2. Project-scoped pack rules
+3. Supplemental-scoped pack rules
+4. Global-scoped pack rules (lowest)
+
+Within the same scope level, declaration order in `rulesPacks` list determines precedence (earlier wins). Duplicate rule IDs at the same scope emit a warning diagnostic.
+
+### PackTargetComponent
+
+A generic `ITargetComponent` implementation that renders output using templates and layout from a template pack. This is the mechanism that enables external targets without dynamic plugin loading.
+
+```csharp
+namespace Steergen.Core.Targets;
+
+///
+/// Generic target component for pack-provided targets.
+/// Delegates all rendering to the pack's Scriban templates and uses
+/// the pack's default layout YAML for routing.
+///
+public sealed class PackTargetComponent : ITargetComponent
+{
+ private readonly string _targetId;
+ private readonly ITemplateProvider _templateProvider;
+ private readonly string _defaultLayoutPath;
+
+ public PackTargetComponent(
+ string targetId,
+ ITemplateProvider templateProvider,
+ string defaultLayoutPath)
+ { }
+
+ public async Task GenerateWithPlanAsync(
+ ResolvedSteeringModel model,
+ TargetConfiguration targetConfig,
+ WritePlan writePlan,
+ string outputBase,
+ CancellationToken cancellationToken = default)
+ { }
+}
+```
+
+Behaviour:
+- Uses the same write-plan-driven generation flow as built-in targets
+- For each planned file, looks up routed rules, builds a generic render model, and renders via the pack's Scriban template
+- The render model exposes the same fields available to built-in targets: `rules`, `targetId`, `filePath`, `formatOptions`
+- The default layout YAML is loaded from the pack directory and fed into `LayoutOverrideLoader` as if it were a built-in layout
+- Pack-provided targets participate in `steergen validate`, `steergen inspect`, and `steergen purge` identically to built-in targets
+
+### Target Registry Extension
+
+The existing static target registry is extended to support pack-provided targets:
+
+```csharp
+namespace Steergen.Core.Targets;
+
+public sealed class TargetRegistry
+{
+ ///
+ /// Returns all available targets: built-in + pack-provided.
+ ///
+ public IReadOnlyList GetAvailableTargets();
+
+ ///
+ /// Registers pack-provided targets from a loaded template pack manifest.
+ ///
+ public void RegisterPackTargets(
+ PackManifest manifest,
+ string packBasePath,
+ ITemplateProvider templateProvider);
+
+ ///
+ /// Returns true if the target is available (built-in or pack-provided).
+ ///
+ public bool IsAvailable(string targetId);
+}
+
+public sealed record TargetDescriptor
+{
+ public required string TargetId { get; init; }
+ public required TargetOrigin Origin { get; init; }
+ public string? Description { get; init; }
+ public string? PackName { get; init; }
+}
+
+public enum TargetOrigin
+{
+ BuiltIn,
+ PackProvided
+}
+```
+
+### Extended SteeringConfiguration
+
+```csharp
+namespace Steergen.Core.Model;
+
+public record SteeringConfiguration
+{
+ // REMOVED: public string? GlobalRoot { get; init; }
+ public string? ProjectRoot { get; init; }
+ public string? GenerationRoot { get; init; }
+ public IReadOnlyList ActiveProfiles { get; init; } = [];
+ public IReadOnlyList Targets { get; init; } = [];
+ public IReadOnlyList RegisteredTargets { get; init; } = [];
+ public string? TemplatePackVersion { get; init; }
+ public TemplatePackConfig? TemplatePack { get; init; }
+ public IReadOnlyList RulesPacks { get; init; } = [];
+}
+
+public sealed record TemplatePackConfig
+{
+ public string? Source { get; init; } // "github:{owner}/{repo}"
+ public string? Ref { get; init; }
+ public string? LocalPath { get; init; } // Alternative: local override path
+}
+
+public sealed record RulesPackEntry
+{
+ public required string Source { get; init; } // "github:{owner}/{repo}"
+ public string? Ref { get; init; }
+ public string? Path { get; init; } // Subdirectory within repo
+ public PackScope? Scope { get; init; } // Consumer scope override
+}
+```
+
+### CLI Commands
+
+New commands added to `Steergen.Cli/Commands/`:
+
+| Command | File | Purpose |
+|---------|------|---------|
+| `steergen template-pack add` | `TemplatePackAddCommand.cs` | Add template pack source to config and download |
+| `steergen template-pack remove` | `TemplatePackRemoveCommand.cs` | Remove template pack from config |
+| `steergen rules-pack add` | `RulesPackAddCommand.cs` | Add rules pack to config and download |
+| `steergen rules-pack remove` | `RulesPackRemoveCommand.cs` | Remove rules pack from config |
+| `steergen rules-pack list` | `RulesPackListCommand.cs` | List configured rules packs with status |
+| `steergen update --templates` | Extended `UpdateCommand.cs` | Re-download template pack |
+| `steergen update --rules` | Extended `UpdateCommand.cs` | Re-download all rules packs |
+| `steergen inspect --templates` | Extended `InspectCommand.cs` | Show template resolution chain |
+| `steergen inspect --rules` | Extended `InspectCommand.cs` | Show rules pack metadata and rule counts |
+
+### GitHubPackSourceParser
+
+Parses the `github:{owner}/{repo}` format from configuration strings.
+
+```csharp
+namespace Steergen.Core.Packs;
+
+public static class GitHubPackSourceParser
+{
+ ///
+ /// Parses "github:{owner}/{repo}" into a GitHubPackSource.
+ /// Returns null if the format is invalid.
+ ///
+ public static GitHubPackSource? Parse(string source, string? refValue = null, string? path = null);
+
+ ///
+ /// Formats a GitHubPackSource back to the canonical string representation.
+ ///
+ public static string Format(GitHubPackSource source);
+}
+```
+
+## Data Models
+
+### Configuration YAML Schema
+
+```yaml
+# steergen.config.yaml
+projectRoot: ./steering
+generationRoot: .
+
+# Template pack configuration (mutually exclusive: source OR localPath)
+templatePack:
+ source: "github:acme-corp/steergen-templates"
+ ref: "v2.1.0" # Tag, branch, or 40-char SHA
+ # OR
+ # localPath: "./custom-templates"
+
+# Rules packs (ordered list, declaration order = precedence within same scope)
+rulesPacks:
+ - source: "github:acme-corp/baseline-rules"
+ ref: "abc123def456789012345678901234567890abcd" # Pinned SHA
+ scope: global # Override manifest scope
+ - source: "github:acme-corp/team-rules"
+ ref: "v1.0.0"
+ path: "backend-team" # Subdirectory within repo
+ - source: "github:acme-corp/security-rules"
+ ref: "main" # Branch (will emit pinning recommendation)
+
+registeredTargets:
+ - kiro
+ - speckit
+```
+
+### Pack Manifest Schema (pack.yaml)
+
+```yaml
+# Template pack manifest (override-only: customises existing targets)
+name: "acme-templates"
+version: "2.1.0"
+minSteergenVersion: "1.5.0"
+targets: # Which built-in targets this pack overrides
+ - kiro
+ - speckit
+```
+
+```yaml
+# Template pack manifest (external target provider)
+name: "cursor-target-pack"
+version: "1.0.0"
+minSteergenVersion: "1.5.0"
+targets: # Override templates for these built-in targets
+ - kiro
+providedTargets: # Complete new target definitions
+ - targetId: "cursor"
+ defaultLayout: "cursor/default-layout.yaml"
+ description: "Cursor AI rules format"
+ - targetId: "windsurf"
+ defaultLayout: "windsurf/default-layout.yaml"
+ description: "Windsurf AI rules format"
+```
+
+```yaml
+# Rules pack manifest
+name: "acme-baseline-rules"
+version: "1.0.0"
+minSteergenVersion: "1.5.0"
+scope: global # global | supplemental | project
+rulesRoot: "rules/" # Optional subdirectory containing .md files
+```
+
+### Local Pack Cache Structure
+
+```
+~/.steergen/
+├── packs/ # Template pack cache
+│ └── {owner}/
+│ └── {repo}/
+│ └── {ref}/
+│ ├── pack.yaml
+│ ├── {targetId}/ # Override templates
+│ │ └── {templateName}.scriban
+│ └── {providedTargetId}/ # External target (full definition)
+│ ├── default-layout.yaml
+│ └── {templateName}.scriban
+└── rules/ # Rules pack cache
+ └── {owner}/
+ └── {repo}/
+ └── {ref}/
+ ├── pack.yaml
+ └── {rulesRoot}/
+ └── *.md
+```
+
+### SteeringRule Extension
+
+The existing `SteeringRule` record is extended with an optional source pack tag:
+
+```csharp
+public record SteeringRule
+{
+ // ... existing fields ...
+ public string? SourcePackName { get; init; }
+ public PackScope? SourcePackScope { get; init; }
+}
+```
+
+## Correctness Properties
+
+*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
+
+### Property 1: Template Override Precedence with Target Scoping
+
+*For any* target ID and template name, and any combination of template availability across the three layers (local override, cached GitHub pack, built-in embedded), the `TemplateResolver` SHALL return the content from the highest-precedence layer that contains the template for that target, where precedence is: local override > cached GitHub pack > built-in embedded. Additionally, *for any* template pack that declares a `targets` list, the resolver SHALL only consult that pack's templates for the declared target IDs and fall through to the next layer for undeclared targets.
+
+**Validates: Requirements 1.1, 1.2, 1.3, 1.4, 15.1, 15.2, 15.3, 15.4**
+
+### Property 2: Pack Manifest Validation
+
+*For any* YAML document presented as a pack manifest, the manifest SHALL be valid if and only if all required fields are present and well-formed: `name` (non-empty string), `version` (valid semver), `minSteergenVersion` (valid semver), and for rules packs additionally `scope` (one of `global`, `supplemental`, `project`).
+
+**Validates: Requirements 2.2, 2.3, 9.3, 9.4**
+
+### Property 3: Version Compatibility Check
+
+*For any* pair of semantic versions (runningVersion, minSteergenVersion), the compatibility check SHALL return compatible if and only if runningVersion >= minSteergenVersion using standard semver comparison (major.minor.patch).
+
+**Validates: Requirements 2.4, 2.6, 13.1, 13.2**
+
+### Property 4: SHA Pinning Detection
+
+*For any* string, the `IsImmutablePin` function SHALL return true if and only if the string is exactly 40 characters long and consists entirely of lowercase hexadecimal characters (0-9, a-f).
+
+**Validates: Requirements 3.6, 10.7**
+
+### Property 5: Cache Path Construction
+
+*For any* valid (owner, repo, ref) tuple and pack type, the computed cache path SHALL equal `{userProfileDirectory}/.steergen/{packTypeDir}/{owner}/{repo}/{ref}/` where packTypeDir is `packs` for template packs and `rules` for rules packs.
+
+**Validates: Requirements 4.1, 12.1**
+
+### Property 6: Template Resolution Determinism
+
+*For any* template resolver state (fixed local override path, cached pack path, and embedded templates), calling `GetTemplate` with the same (targetId, templateName) arguments SHALL always return the same content. Additionally, *for any* set of template files in a pack directory, the enumeration order SHALL be deterministic (ordinal string sort of relative paths).
+
+**Validates: Requirements 5.1, 5.4**
+
+### Property 7: Template Pack Validation
+
+*For any* string content, the template validation SHALL report it as valid if and only if the Scriban parser can parse it without errors. Additionally, *for any* template file name in a pack, validation SHALL report a warning if the file name does not match a known template name for the declared target IDs.
+
+**Validates: Requirements 6.1, 6.3**
+
+### Property 8: Configuration Round-Trip
+
+*For any* valid `SteeringConfiguration` containing template pack and rules pack entries, serializing to YAML and deserializing back SHALL produce an equivalent configuration (all fields preserved including source, ref, path, and scope for each pack entry).
+
+**Validates: Requirements 3.1, 10.1, 10.2**
+
+### Property 9: Rules Merge with Scope-Based Precedence
+
+*For any* set of rules from project-local sources and rules packs at various scopes, the merge SHALL resolve duplicate rule IDs by selecting the rule from the highest-precedence source, where precedence is: project-local > project-scoped packs > supplemental-scoped packs > global-scoped packs. Within the same scope level, *for any* two packs declaring the same rule ID, the rule from the pack declared earlier in the `rulesPacks` list SHALL win. When a consumer scope override is specified, the merge SHALL use the overridden scope instead of the manifest-declared scope.
+
+**Validates: Requirements 10.3, 10.4, 10.5, 10.6, 11.5, 11.7**
+
+### Property 10: Rule Source Tagging
+
+*For any* rule loaded from a rules pack, the resolved rule SHALL carry a `SourcePackName` equal to the pack's manifest `name` field and a `SourcePackScope` equal to the effective scope used during merge.
+
+**Validates: Requirements 11.6**
+
+### Property 11: Rules Pack File Discovery
+
+*For any* directory tree, the rules pack file discovery SHALL return all and only files with the `.md` extension found recursively under the rules root, enumerated in deterministic ordinal sort order, excluding symbolic links.
+
+**Validates: Requirements 11.1**
+
+### Property 12: File Size Limit Enforcement
+
+*For any* file presented to the template resolver or rules pack loader, the file SHALL be rejected with a diagnostic error if its size exceeds 1,048,576 bytes (1 MB), and accepted for processing if its size is at or below that threshold.
+
+**Validates: Requirements 14.2, 14.7**
+
+### Property 13: Path Traversal Rejection
+
+*For any* file path extracted from a downloaded archive, the path SHALL be rejected if it contains the sequence `../` or if the resolved path would place the file outside the expected pack directory structure. All paths without traversal sequences that resolve within the pack directory SHALL be accepted.
+
+**Validates: Requirements 14.3, 14.4**
+
+### Property 14: External Target Registration Consistency
+
+*For any* template pack manifest declaring `providedTargets`, the target registry SHALL make those targets available for generation if and only if the referenced `defaultLayout` file exists within the pack directory. Additionally, *for any* registered target (built-in or pack-provided), the `IsAvailable` check SHALL return true, and for any unregistered target ID, it SHALL return false.
+
+**Validates: Requirements 16.1, 16.3, 16.4, 16.6**
+
+### Property 15: Pack-Provided Target Rendering Equivalence
+
+*For any* set of routed rules and write plan, a `PackTargetComponent` SHALL produce output by rendering the pack's Scriban templates with the same model fields available to built-in targets. The rendered output SHALL be deterministic for identical inputs.
+
+**Validates: Requirements 16.5, 16.7**
+
+## Error Handling
+
+### Diagnostic Categories
+
+| Code | Severity | Condition |
+|------|----------|-----------|
+| TP001 | Error | Configured `templatePackPath` does not exist |
+| TP002 | Error | Template file exceeds 1 MB size limit |
+| TP003 | Error | Template file contains Scriban syntax errors |
+| TP004 | Warning | Template pack missing `pack.yaml` (legacy mode) |
+| TP005 | Error | Template pack version incompatible with running Steergen |
+| TP006 | Warning | Template pack contains files for undeclared target |
+| TP007 | Error | Configured GitHub pack not in local cache |
+| TP008 | Warning | Template pack uses branch ref (recommend pinning) |
+| TP009 | Error | Provided target's `defaultLayout` file missing from pack |
+| TP010 | Error | Registered target not available (pack removed) |
+| TP011 | Warning | Template files found under target ID not in `targets` list |
+| RP001 | Error | Rules pack missing `pack.yaml` |
+| RP002 | Error | Rules pack version incompatible with running Steergen |
+| RP003 | Error | Rules pack document fails validation |
+| RP004 | Warning | Duplicate rule ID across same-scope packs |
+| RP005 | Error | Configured rules pack not in local cache |
+| RP006 | Warning | Rules pack uses branch ref (recommend pinning) |
+| RP007 | Error | Rules pack document exceeds 1 MB size limit |
+| DL001 | Error | GitHub repository not accessible (includes HTTP status and URL) |
+| DL002 | Error | Downloaded archive missing `pack.yaml` |
+| DL003 | Error | Archive contains path traversal sequences |
+| DL004 | Error | Archive contains files outside expected structure |
+| CFG001 | Error | Deprecated `globalRoot` field present in config |
+
+### Exit Codes
+
+- **0**: Success
+- **2**: Configuration or validation error (non-recoverable without user action)
+
+### Fail-Closed Behaviour
+
+- Missing cache → error with remediation instruction (`steergen update --templates` or `--rules`)
+- Invalid manifest → error, pack not loaded
+- Path traversal detected → error, archive discarded
+- Version incompatibility → error, pack not loaded
+- `globalRoot` present → error, generation refused
+
+## Testing Strategy
+
+### Property-Based Tests (CsCheck + xUnit)
+
+PBT is the primary test strategy for this feature. Each correctness property maps to one or more property-based tests in `tests/Steergen.Core.PropertyTests/Packs/`.
+
+| Property | Test Class | Generator Strategy |
+|----------|-----------|-------------------|
+| 1: Override Precedence + Target Scoping | `TemplateResolverProperties` | Generate random (targetId, templateName) pairs with random availability across layers and random declared-targets sets |
+| 2: Manifest Validation | `PackManifestProperties` | Generate random YAML documents with random field presence/absence |
+| 3: Version Compatibility | `VersionCompatibilityProperties` | Generate random semver pairs |
+| 4: SHA Pinning | `ShaDetectionProperties` | Generate random strings including valid/invalid 40-char hex |
+| 5: Cache Path | `CachePathProperties` | Generate random (owner, repo, ref) tuples |
+| 6: Determinism | `TemplateResolverProperties` | Generate random resolver states, call twice, assert equality |
+| 7: Template Validation | `TemplateValidationProperties` | Generate random Scriban-like strings |
+| 8: Config Round-Trip | `ConfigurationProperties` | Generate random SteeringConfiguration with pack entries |
+| 9: Merge Precedence | `RulesMergeProperties` | Generate random rule sets at random scopes with overlapping IDs |
+| 10: Source Tagging | `RulesMergeProperties` | Generate random rules from random packs |
+| 11: File Discovery | `FileDiscoveryProperties` | Generate random directory trees |
+| 12: File Size Limit | `FileSizeLimitProperties` | Generate random file sizes around the 1 MB boundary |
+| 13: Path Traversal | `PathTraversalProperties` | Generate random paths including traversal sequences |
+| 14: External Target Registration | `TargetRegistryProperties` | Generate random manifests with providedTargets, random layout file presence |
+| 15: Pack Target Rendering | `PackTargetComponentProperties` | Generate random rule sets and write plans, verify deterministic rendering |
+
+**Configuration**: Minimum 100 iterations per property test. Each test tagged with:
+```
+// Feature: custom-template-packs, Property {N}: {property_text}
+```
+
+### Unit Tests (xUnit + NSubstitute)
+
+Example-based tests for scenarios where PBT is not practical:
+
+- CLI command parsing and dispatch (integration with System.CommandLine)
+- HTTP client interaction with mocked responses (download success/failure)
+- `globalRoot` deprecation error diagnostic
+- Symlink rejection behaviour (platform-specific)
+- `steergen inspect` output formatting
+
+### Integration Tests
+
+End-to-end CLI tests in `tests/Steergen.Cli.IntegrationTests/`:
+
+- `steergen template-pack add/remove` modifies config correctly
+- `steergen rules-pack add/remove/list` modifies config correctly
+- `steergen update --templates` downloads and caches
+- `steergen update --rules` downloads and caches
+- `steergen run` with template pack produces overridden output
+- `steergen run` with rules packs merges rules correctly
+- `steergen validate` with malformed template pack reports errors
+- `steergen run` with `globalRoot` in config fails with CFG001
+
+### Performance Budget
+
+- Template resolution: < 1ms per template lookup (filesystem stat + read)
+- Rules pack loading: < 100ms for 50 documents across 5 packs
+- Pack download: bounded by network; no performance budget (offline after first download)
+- No regression in `steergen run` latency for the default (no-pack) configuration
+
+### Security Test Corpus
+
+- Archives with `../../../etc/passwd` path entries → rejected
+- Archives with absolute paths → rejected
+- Template files containing `{{ include }}` with path traversal → Scriban sandboxing prevents escape
+- Rules pack documents with prompt-injection payloads in rule text → parsed as literal text, no execution
+- Symlinks in pack directories → not followed
+- Files at exactly 1 MB and 1 MB + 1 byte → boundary enforcement verified
diff --git a/.kiro/specs/custom-template-packs/requirements.md b/.kiro/specs/custom-template-packs/requirements.md
new file mode 100644
index 0000000..914b643
--- /dev/null
+++ b/.kiro/specs/custom-template-packs/requirements.md
@@ -0,0 +1,257 @@
+# Requirements Document
+
+## Introduction
+
+Custom Template Packs and Rules Packs extends Steergen with two capabilities: user-provided Scriban templates that override built-in rendering, and shared governance rule sets published to GitHub repositories. This feature also retires the legacy `globalRoot` configuration concept, replacing it with rules packs that declare their scope. Organisations can now publish departmental, team, or baseline rule sets as packs, reference them in `steergen.config.yaml`, and have them loaded alongside project-local rules during generation. Template packs customise the rendered output format for specific targets or provide complete target definitions for new external targets. Both pack types can be stored locally or published to GitHub repositories. This maintains determinism and the existing architecture principles (no dynamic plugin loading, additive-only changes).
+
+## Clarifications
+
+### Session 2026-05-18
+
+- Q: How should the Pack_Downloader handle GitHub rate limiting and authentication for downloads? → A: Use unauthenticated public archive URLs only (`https://github.com/{owner}/{repo}/archive/{ref}.tar.gz`). No GitHub REST API requiring tokens. Private repositories are out of scope.
+- Q: How should the Pack_Downloader handle failed or partial downloads relative to existing cache? → A: Atomic replacement — download to temp directory, validate, then swap into cache. Existing cache is preserved on failure.
+- Q: What output should pack download/update operations emit? → A: Minimal structured output — pack name + version on success; diagnostic code + message on failure. No verbose progress by default.
+
+## Glossary
+
+- **Template_Pack**: A directory containing one or more Scriban template files organised by target ID, following the same naming convention as the embedded templates (e.g., `{targetId}/{templateName}.scriban`). A template pack may override templates for specific built-in targets or provide complete target definitions for new external targets.
+- **External_Target_Pack**: A template pack that provides a complete target definition including templates, a default layout YAML, and target metadata, enabling new targets to be distributed without modifying the Steergen binary
+- **Target_Declaration**: A section in the pack manifest that declares which targets the pack provides templates for, distinguishing between override targets (customising existing built-in targets) and provided targets (supplying complete new target definitions)
+- **Template_Resolver**: The component responsible for locating the correct template for a given target and template name, applying the override precedence chain
+- **Pack_Manifest**: A YAML metadata file (`pack.yaml`) at the root of a template pack or rules pack that declares the pack name, version, compatible Steergen version range, and content coverage
+- **GitHub_Pack_Source**: A reference in configuration to a GitHub repository containing a published template pack or rules pack, specified as `owner/repo` with an optional tag or branch
+- **Local_Pack_Cache**: A well-known directory on the local filesystem where downloaded template packs and rules packs are stored for offline use
+- **Override_Precedence**: The resolution order for templates: local override path > downloaded GitHub pack > built-in embedded templates
+- **Steergen_CLI**: The command-line interface entry point for the Steergen tool
+- **Pack_Downloader**: The component responsible for fetching template packs and rules packs from GitHub repositories to the local pack cache
+- **Rules_Pack**: A directory containing one or more steering document files (Markdown with YAML frontmatter and `:::rule` blocks) published to a GitHub repository for shared use across projects
+- **Rules_Pack_Loader**: The component responsible for discovering and loading steering documents from configured rules packs, merging them into the resolved steering model alongside project rules
+- **Rules_Pack_Manifest**: A YAML metadata file (`pack.yaml`) at the root of a rules pack that declares the pack name, version, compatible Steergen version range, scope, and content metadata
+- **Pack_Scope**: A declaration in the rules pack manifest indicating how the pack's rules should be treated during merge: `global` (baseline rules, lowest precedence), `supplemental` (mid-precedence, between global and project), or `project` (highest precedence, equivalent to local project rules)
+- **Rules_Merge_Order**: The resolution order for steering rules during merge: project-local rules > project-scoped packs > supplemental-scoped packs > global-scoped packs
+
+## Requirements
+
+### Requirement 1: Local Template Override Resolution
+
+**User Story:** As a Steergen user, I want to provide local Scriban template files that override the built-in templates for any target, so that I can customise the generated output format without modifying the tool itself.
+
+#### Acceptance Criteria
+
+1. WHEN a `templatePackPath` is configured in `steergen.config.yaml`, THE Template_Resolver SHALL load templates from that local directory before falling back to built-in embedded templates
+2. WHEN a template file exists in the configured local pack path matching the pattern `{targetId}/{templateName}.scriban`, THE Template_Resolver SHALL use that file content instead of the corresponding embedded resource
+3. WHEN a template file does not exist in the configured local pack path for a given target and template name, THE Template_Resolver SHALL fall back to the built-in embedded template
+4. THE Template_Resolver SHALL resolve templates using the Override_Precedence order: local override path, then downloaded GitHub pack, then built-in embedded templates
+5. IF the configured `templatePackPath` does not exist on the filesystem, THEN THE Steergen_CLI SHALL report a diagnostic error and exit with code 2
+
+### Requirement 2: Template Pack Manifest
+
+**User Story:** As a template pack author, I want to declare metadata about my template pack in a manifest file, so that consumers can verify compatibility and understand what the pack provides.
+
+#### Acceptance Criteria
+
+1. THE Pack_Manifest SHALL be a YAML file named `pack.yaml` located at the root of a template pack directory
+2. THE Pack_Manifest SHALL contain the following required fields: `name`, `version`, and `minSteergenVersion`
+3. THE Pack_Manifest SHALL contain an optional `targets` field listing the target IDs that the pack provides templates for
+4. WHEN a template pack is loaded, THE Template_Resolver SHALL parse the Pack_Manifest and validate that the declared `minSteergenVersion` is compatible with the running Steergen version
+5. IF the Pack_Manifest is missing from a configured template pack directory, THEN THE Steergen_CLI SHALL report a diagnostic warning and treat the directory as a legacy pack without version constraints
+6. IF the running Steergen version is lower than the declared `minSteergenVersion`, THEN THE Steergen_CLI SHALL report a diagnostic error indicating version incompatibility and exit with code 2
+
+### Requirement 15: Target-Scoped Template Overrides
+
+**User Story:** As a template pack author, I want to declare which specific targets my pack overrides, so that consumers know which targets are affected and the resolver only applies my templates to the declared targets.
+
+#### Acceptance Criteria
+
+1. THE Pack_Manifest `targets` field SHALL list the target IDs that the pack provides templates for
+2. WHEN a template pack declares a `targets` list, THE Template_Resolver SHALL only use templates from that pack for the declared target IDs
+3. WHEN a template pack does not declare a `targets` list, THE Template_Resolver SHALL treat the pack as providing templates for all targets (backward-compatible behaviour)
+4. WHEN a template pack declares targets that include both built-in and external targets, THE Template_Resolver SHALL apply override resolution independently per target
+5. THE Template_Resolver SHALL ignore template files in a pack that are organised under a target ID not declared in the pack's `targets` list and report a diagnostic warning
+
+### Requirement 16: External Target Packs
+
+**User Story:** As a target author, I want to publish a template pack that provides a complete target definition including templates and default layout, so that new targets can be distributed and used without modifying the Steergen binary.
+
+#### Acceptance Criteria
+
+1. THE Pack_Manifest SHALL support a `providedTargets` section listing target IDs that the pack fully defines (as opposed to `targets` which lists overrides of existing built-in targets)
+2. EACH entry in `providedTargets` SHALL include a `targetId`, a `defaultLayout` field referencing a layout YAML file within the pack, and an optional `description` field
+3. WHEN a template pack declares `providedTargets`, THE Template_Resolver SHALL register those targets as available for generation, using the pack's templates and default layout
+4. WHEN a provided target's `defaultLayout` file is missing from the pack, THE Steergen_CLI SHALL report a diagnostic error and refuse to load the target
+5. THE provided target SHALL participate in the same routing and write-plan pipeline as built-in targets, receiving routed rules and rendering via its pack-supplied Scriban templates
+6. WHEN a user registers a provided target via `steergen target add {targetId}`, THE Steergen_CLI SHALL verify that the target is available either as a built-in or from a configured template pack's `providedTargets`
+7. THE provided target SHALL use the same `ITargetComponent` contract as built-in targets, with a generic pack-based implementation that delegates rendering to the pack's templates
+8. WHEN a template pack providing a target is removed, THE Steergen_CLI SHALL report a diagnostic error if the target is still registered in `registeredTargets`
+
+### Requirement 3: GitHub Pack Source Configuration
+
+**User Story:** As a Steergen user, I want to reference a template pack published in a GitHub repository from my configuration, so that my team can share custom templates without manual file distribution.
+
+#### Acceptance Criteria
+
+1. THE SteeringConfiguration SHALL support a `templatePack` section with a `source` field accepting the format `github:{owner}/{repo}` and an optional `ref` field for a Git tag, branch, or commit SHA
+2. WHEN a `templatePack.source` is configured with a GitHub reference, THE Pack_Downloader SHALL fetch the pack contents from the specified repository and ref
+3. WHEN no `ref` is specified in the GitHub pack source, THE Pack_Downloader SHALL use the repository default branch
+4. THE Pack_Downloader SHALL store downloaded template packs in the Local_Pack_Cache directory
+5. IF the GitHub repository is not accessible, THEN THE Pack_Downloader SHALL report a diagnostic error with the HTTP status and repository URL
+6. WHEN a `ref` field specifies a full 40-character Git commit SHA, THE Pack_Downloader SHALL treat the template pack as immutably pinned and skip re-download even when `steergen update --templates` is executed unless `--force` is also specified
+7. THE Steergen_CLI SHALL recommend pinning template packs to a commit SHA or tag in diagnostic output when a branch ref is used, to ensure deterministic template resolution
+
+### Requirement 4: Template Pack Download and Caching
+
+**User Story:** As a Steergen user, I want downloaded template packs to be cached locally, so that generation works offline after the initial download and remains deterministic across runs.
+
+#### Acceptance Criteria
+
+1. THE Local_Pack_Cache for template packs SHALL be located at `{userProfileDirectory}/.steergen/packs/{owner}/{repo}/{ref}/`
+2. WHEN a template pack has already been downloaded for the configured source and ref, THE Pack_Downloader SHALL use the cached version without making network requests
+3. WHEN the `steergen update --templates` command is executed, THE Pack_Downloader SHALL re-download the configured template pack regardless of cache state
+4. THE Pack_Downloader SHALL download packs as GitHub archive tarballs using the unauthenticated public archive URL (`https://github.com/{owner}/{repo}/archive/{ref}.tar.gz`) which does not require API tokens or authentication
+5. THE Pack_Downloader SHALL only support public GitHub repositories; private repositories requiring authentication are out of scope
+6. WHEN a download completes, THE Pack_Downloader SHALL verify that the downloaded archive contains a valid Pack_Manifest before storing it in the cache
+7. IF the downloaded archive does not contain a valid Pack_Manifest, THEN THE Pack_Downloader SHALL report a diagnostic error and discard the download
+8. THE Pack_Downloader SHALL use atomic replacement when updating the cache: download and extract to a temporary directory, validate the pack manifest, then atomically swap the temporary directory into the cache location, preserving the existing cache on failure
+
+### Requirement 5: Deterministic Template Resolution
+
+**User Story:** As a Steergen user, I want template resolution to be deterministic, so that identical inputs and configuration always produce identical outputs regardless of network availability.
+
+#### Acceptance Criteria
+
+1. THE Template_Resolver SHALL produce identical output for identical inputs, configuration, and cached template pack state
+2. THE Template_Resolver SHALL resolve templates without making network requests during `steergen run` or `steergen validate` commands
+3. WHEN a configured GitHub pack source has not been downloaded to the Local_Pack_Cache, THE Steergen_CLI SHALL report a diagnostic error instructing the user to run `steergen update --templates` and exit with code 2
+4. THE Template_Resolver SHALL use deterministic file enumeration order when discovering template files in a pack directory
+
+### Requirement 6: Template Pack Validation
+
+**User Story:** As a Steergen user, I want the tool to validate my custom template pack, so that I receive clear diagnostics when templates are malformed or incompatible.
+
+#### Acceptance Criteria
+
+1. WHEN `steergen validate` is executed with a template pack configured, THE Steergen_CLI SHALL validate that all template files in the pack are parseable Scriban templates
+2. WHEN a template file in the pack contains Scriban syntax errors, THE Steergen_CLI SHALL report the file path, line number, and error description
+3. THE Steergen_CLI SHALL validate that template file names in the pack match known template names for the declared target IDs
+4. IF a template pack contains files for a target ID that is not registered, THEN THE Steergen_CLI SHALL report a diagnostic warning
+
+### Requirement 7: CLI Integration for Template Pack Management
+
+**User Story:** As a Steergen user, I want CLI commands to manage template packs, so that I can add, download, update, and inspect template pack state from the command line without hand-editing configuration files.
+
+#### Acceptance Criteria
+
+1. WHEN `steergen template-pack add github:{owner}/{repo}` is executed, THE Steergen_CLI SHALL add the template pack source to `steergen.config.yaml` and download it to the Local_Pack_Cache
+2. WHEN `steergen template-pack add github:{owner}/{repo} --ref {ref}` is executed, THE Steergen_CLI SHALL record the specified ref in the configuration
+3. WHEN `steergen template-pack add` is executed with a `--path {localPath}` option, THE Steergen_CLI SHALL set the `templatePackPath` in configuration to the specified local directory
+4. WHEN `steergen template-pack remove` is executed, THE Steergen_CLI SHALL remove the template pack configuration from `steergen.config.yaml`
+5. WHEN `steergen update --templates` is executed, THE Steergen_CLI SHALL download or re-download the configured template pack from the GitHub source to the Local_Pack_Cache
+6. WHEN `steergen inspect --templates` is executed, THE Steergen_CLI SHALL display the active template resolution chain showing which templates come from which source (local override, cached GitHub pack, or built-in)
+7. WHEN `steergen update --templates` completes successfully, THE Steergen_CLI SHALL display the pack name, version, and number of template files downloaded
+8. IF no template pack is configured when `steergen update --templates` is executed, THEN THE Steergen_CLI SHALL report that no template pack source is configured and exit with code 0
+
+### Requirement 8: Removal of Global Root Configuration
+
+**User Story:** As a Steergen user, I want the `globalRoot` configuration to be removed and replaced by rules packs, so that all shared governance rules are managed through a single consistent pack-based mechanism.
+
+#### Acceptance Criteria
+
+1. THE SteeringConfiguration SHALL remove the `globalRoot` field entirely from the configuration schema
+2. IF `globalRoot` is present in `steergen.config.yaml`, THEN THE Steergen_CLI SHALL report a diagnostic error stating that `globalRoot` has been removed and rules packs should be used instead, and exit with code 2
+3. THE Steergen_CLI SHALL remove all code paths that discover and load steering documents from a `globalRoot` directory
+
+### Requirement 9: Rules Pack Publishing to GitHub
+
+**User Story:** As a steering document author, I want to publish rule sets to GitHub repositories, so that teams across the organisation can share and reuse governance rules without copying files between projects.
+
+#### Acceptance Criteria
+
+1. THE Rules_Pack SHALL be a GitHub repository (or subdirectory within a repository) containing one or more steering document files (Markdown with YAML frontmatter and `:::rule` blocks)
+2. THE Rules_Pack SHALL contain a Rules_Pack_Manifest file (`pack.yaml`) at the pack root declaring pack metadata
+3. THE Rules_Pack_Manifest SHALL contain the following required fields: `name`, `version`, `minSteergenVersion`, and `scope` (one of `global`, `supplemental`, or `project`)
+4. THE Rules_Pack_Manifest SHALL contain an optional `rulesRoot` field specifying the subdirectory containing steering documents (defaulting to the pack root directory)
+5. THE Rules_Pack SHALL support publishing multiple independent rule sets within a single GitHub repository by using distinct subdirectories, each with its own `pack.yaml`
+
+### Requirement 10: Rules Pack Configuration
+
+**User Story:** As a Steergen user, I want to reference one or more rules packs in my `steergen.config.yaml`, so that shared governance rules are loaded alongside my project rules without manual file management.
+
+#### Acceptance Criteria
+
+1. THE SteeringConfiguration SHALL support a `rulesPacks` list where each entry specifies a `source` field accepting the format `github:{owner}/{repo}` and an optional `ref` field for a Git tag, branch, or commit SHA
+2. THE SteeringConfiguration SHALL support an optional `path` field per rules pack entry to reference a subdirectory within the repository when multiple rule sets are published in one repo
+3. WHEN multiple rules packs are configured, THE Rules_Pack_Loader SHALL load them in the order declared in the `rulesPacks` list
+4. THE Rules_Pack_Loader SHALL apply Rules_Merge_Order when merging rules: project-local rules override project-scoped packs, which override supplemental-scoped packs, which override global-scoped packs
+5. WHEN two rules packs at the same scope level declare rules with the same rule ID, THE Rules_Pack_Loader SHALL use the rule from the pack declared earlier in the `rulesPacks` list and report a diagnostic warning about the duplicate
+6. THE SteeringConfiguration SHALL support an optional `scope` field per rules pack entry that overrides the scope declared in the pack manifest, allowing consumers to elevate or demote a pack's precedence
+7. WHEN a `ref` field specifies a full 40-character Git commit SHA, THE Rules_Pack_Loader SHALL treat the pack as immutably pinned and skip re-download even when `steergen update --rules` is executed unless `--force` is also specified
+8. THE Steergen_CLI SHALL recommend pinning rules packs to a commit SHA or tag in diagnostic output when a branch ref is used, to ensure deterministic rule resolution
+
+### Requirement 17: CLI Integration for Rules Pack Management
+
+**User Story:** As a Steergen user, I want CLI commands to add, remove, and manage rules packs, so that I can configure shared governance rules from the command line without hand-editing configuration files.
+
+#### Acceptance Criteria
+
+1. WHEN `steergen rules-pack add github:{owner}/{repo}` is executed, THE Steergen_CLI SHALL append the rules pack source to the `rulesPacks` list in `steergen.config.yaml` and download it to the Local_Pack_Cache
+2. WHEN `steergen rules-pack add github:{owner}/{repo} --ref {ref}` is executed, THE Steergen_CLI SHALL record the specified ref in the configuration entry
+3. WHEN `steergen rules-pack add github:{owner}/{repo} --path {subdir}` is executed, THE Steergen_CLI SHALL record the specified subdirectory path in the configuration entry
+4. WHEN `steergen rules-pack add github:{owner}/{repo} --scope {scope}` is executed, THE Steergen_CLI SHALL record the specified scope override in the configuration entry
+5. WHEN `steergen rules-pack remove {name}` is executed, THE Steergen_CLI SHALL remove the matching rules pack entry from `steergen.config.yaml`
+6. WHEN `steergen rules-pack list` is executed, THE Steergen_CLI SHALL display all configured rules packs with their source, ref, scope, and cache status
+7. WHEN `steergen update --rules` is executed, THE Steergen_CLI SHALL re-download all configured rules packs regardless of cache state
+8. WHEN `steergen inspect --rules` is executed, THE Steergen_CLI SHALL display all configured rules packs with their name, version, source, scope, and number of rules loaded
+
+### Requirement 11: Rules Pack Loading and Merging
+
+**User Story:** As a Steergen user, I want rules from configured packs to be loaded and merged with my project rules during generation, so that shared governance rules are applied to all targets.
+
+#### Acceptance Criteria
+
+1. WHEN `steergen run` is executed with rules packs configured, THE Rules_Pack_Loader SHALL discover all steering document files recursively from each cached rules pack directory
+2. THE Rules_Pack_Loader SHALL parse rules pack documents using the same Markdown parser used for local steering documents
+3. THE Rules_Pack_Loader SHALL validate rules pack documents using the same validation rules applied to local steering documents
+4. WHEN a rules pack document fails validation, THE Steergen_CLI SHALL report the pack name, file path, and validation errors
+5. THE Rules_Pack_Loader SHALL merge rules pack documents into the resolved steering model according to the Rules_Merge_Order
+6. THE Rules_Pack_Loader SHALL tag each loaded rule with its source pack name for traceability in `steergen inspect` output
+7. WHEN a rules pack declares `scope: global`, THE Rules_Pack_Loader SHALL treat its rules as baseline rules with the lowest merge precedence
+
+### Requirement 12: Rules Pack Download and Caching
+
+**User Story:** As a Steergen user, I want rules packs to be downloaded and cached locally, so that generation works offline and remains deterministic after the initial download.
+
+#### Acceptance Criteria
+
+1. THE Local_Pack_Cache for rules packs SHALL be located at `{userProfileDirectory}/.steergen/rules/{owner}/{repo}/{ref}/`
+2. WHEN a rules pack has already been downloaded for the configured source and ref, THE Rules_Pack_Loader SHALL use the cached version without making network requests
+3. WHEN `steergen update --rules` is executed, THE Pack_Downloader SHALL re-download all configured rules packs regardless of cache state
+4. THE Pack_Downloader SHALL download rules packs using the same mechanism as template packs (unauthenticated GitHub archive tarballs via public archive URL)
+5. WHEN a download completes, THE Pack_Downloader SHALL verify that the downloaded archive contains a valid Rules_Pack_Manifest before storing it in the cache
+6. IF the downloaded archive does not contain a valid Rules_Pack_Manifest, THEN THE Pack_Downloader SHALL report a diagnostic error and discard the download
+7. WHEN a configured rules pack has not been downloaded to the Local_Pack_Cache, THE Steergen_CLI SHALL report a diagnostic error instructing the user to run `steergen update --rules` and exit with code 2
+
+### Requirement 13: Rules Pack Manifest Validation
+
+**User Story:** As a Steergen user, I want rules pack manifests to be validated for compatibility, so that I receive clear diagnostics when a pack is incompatible with my Steergen version.
+
+#### Acceptance Criteria
+
+1. WHEN a rules pack is loaded, THE Rules_Pack_Loader SHALL parse the Rules_Pack_Manifest and validate that the declared `minSteergenVersion` is compatible with the running Steergen version
+2. IF the running Steergen version is lower than the declared `minSteergenVersion` in a rules pack, THEN THE Steergen_CLI SHALL report a diagnostic error indicating version incompatibility and exit with code 2
+3. IF the Rules_Pack_Manifest is missing from a configured rules pack directory, THEN THE Steergen_CLI SHALL report a diagnostic error and refuse to load the pack
+4. WHEN `steergen inspect --rules` is executed, THE Steergen_CLI SHALL display all configured rules packs with their name, version, source, scope, and number of rules loaded
+
+### Requirement 14: Security and Integrity
+
+**User Story:** As a Steergen user, I want template and rules pack loading to be safe from injection attacks, so that malicious content cannot compromise the tool or its output.
+
+#### Acceptance Criteria
+
+1. THE Template_Resolver SHALL treat all template file content as untrusted data and parse it exclusively through the Scriban template engine without executing arbitrary code
+2. THE Template_Resolver SHALL reject template files larger than 1 MB with a diagnostic error
+3. THE Pack_Downloader SHALL validate that downloaded archive contents do not contain path traversal sequences (e.g., `../`) in file paths
+4. THE Pack_Downloader SHALL reject archives containing files outside the expected pack directory structure
+5. THE Template_Resolver SHALL not follow symbolic links when resolving template files from local or cached pack directories
+6. THE Rules_Pack_Loader SHALL treat all rules pack document content as untrusted data and parse it exclusively through the existing steering document parser
+7. THE Rules_Pack_Loader SHALL reject individual steering document files larger than 1 MB with a diagnostic error
+8. THE Rules_Pack_Loader SHALL not follow symbolic links when discovering steering documents in cached rules pack directories
diff --git a/.kiro/specs/custom-template-packs/tasks.md b/.kiro/specs/custom-template-packs/tasks.md
new file mode 100644
index 0000000..bbdff62
--- /dev/null
+++ b/.kiro/specs/custom-template-packs/tasks.md
@@ -0,0 +1,410 @@
+# Implementation Plan: Custom Template Packs and Rules Packs
+
+## Overview
+
+This plan implements two pack-based extensibility mechanisms for Steergen: Template Packs (user-provided Scriban templates that override built-in rendering or provide external targets) and Rules Packs (shared governance rule sets from GitHub repositories), while retiring the legacy `globalRoot` configuration. Implementation follows test-first (Red-Green-Refactor) with CsCheck property-based testing as the primary strategy. Language: C# 14 / .NET 10.
+
+## Tasks
+
+- [x] 1. Core data models and pack manifest infrastructure
+ - [x] 1.1 Create pack data models in `src/Steergen.Core/Packs/`
+ - Create `PackManifest.cs` with `Name`, `Version`, `MinSteergenVersion`, `Scope`, `Targets`, `ProvidedTargets`, `RulesRoot` fields
+ - Create `ProvidedTargetDefinition.cs` with `TargetId`, `DefaultLayout`, `Description`
+ - Create `PackScope.cs` enum (`Global`, `Supplemental`, `Project`)
+ - Create `GitHubPackSource.cs` with `Owner`, `Repo`, `Ref`, `Path`
+ - Create `PackDownloadResult.cs` with `Success`, `CachePath`, `Diagnostics`
+ - Create `PackType.cs` enum (`Template`, `Rules`)
+ - _Requirements: 2.1, 2.2, 2.3, 9.3, 9.4, 16.1, 16.2_
+
+ - [x] 1.2 Write property test for pack manifest validation (Property 2)
+ - **Property 2: Pack Manifest Validation**
+ - Generate random YAML documents with random field presence/absence; assert valid iff all required fields present and well-formed
+ - Test class: `PackManifestProperties` in `tests/Steergen.Core.PropertyTests/Packs/`
+ - **Validates: Requirements 2.2, 2.3, 9.3, 9.4**
+
+ - [x] 1.3 Implement `PackManifestParser` with `Parse` and `Validate` methods in `src/Steergen.Core/Packs/PackManifestParser.cs`
+ - Parse `pack.yaml` from a given directory using YamlDotNet
+ - Return null if `pack.yaml` does not exist
+ - Validate required fields: `name` (non-empty), `version` (valid semver), `minSteergenVersion` (valid semver)
+ - For rules packs, additionally validate `scope` is one of `global`, `supplemental`, `project`
+ - Return diagnostics for missing/invalid fields
+ - _Requirements: 2.1, 2.2, 2.4, 2.5, 9.3_
+
+ - [x] 1.4 Write property test for version compatibility check (Property 3)
+ - **Property 3: Version Compatibility Check**
+ - Generate random semver pairs; assert compatible iff runningVersion >= minSteergenVersion
+ - Test class: `VersionCompatibilityProperties` in `tests/Steergen.Core.PropertyTests/Packs/`
+ - **Validates: Requirements 2.4, 2.6, 13.1, 13.2**
+
+ - [x] 1.5 Write property test for SHA pinning detection (Property 4)
+ - **Property 4: SHA Pinning Detection**
+ - Generate random strings including valid/invalid 40-char hex; assert `IsImmutablePin` returns true iff exactly 40 lowercase hex chars
+ - Test class: `ShaDetectionProperties` in `tests/Steergen.Core.PropertyTests/Packs/`
+ - **Validates: Requirements 3.6, 10.7**
+
+ - [x] 1.6 Write property test for cache path construction (Property 5)
+ - **Property 5: Cache Path Construction**
+ - Generate random (owner, repo, ref) tuples; assert computed path equals `{userProfile}/.steergen/{packTypeDir}/{owner}/{repo}/{ref}/`
+ - Test class: `CachePathProperties` in `tests/Steergen.Core.PropertyTests/Packs/`
+ - **Validates: Requirements 4.1, 12.1**
+
+- [x] 2. Checkpoint - Ensure all tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+- [x] 3. Template resolution engine
+ - [x] 3.1 Create `ITemplateProvider` interface and `TemplateSource` enum in `src/Steergen.Core/Targets/`
+ - Define `GetTemplate(string targetId, string templateName)` method
+ - Define `TemplateSource` enum: `LocalOverride`, `CachedGitHubPack`, `BuiltInEmbedded`, `ProvidedTarget`
+ - _Requirements: 1.4, 5.1_
+
+ - [x] 3.2 Write property test for template override precedence with target scoping (Property 1)
+ - **Property 1: Template Override Precedence with Target Scoping**
+ - Generate random (targetId, templateName) pairs with random availability across layers and random declared-targets sets
+ - Assert resolver returns content from highest-precedence layer; assert target-scoped packs only consulted for declared targets
+ - Test class: `TemplateResolverProperties` in `tests/Steergen.Core.PropertyTests/Packs/`
+ - **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 15.1, 15.2, 15.3, 15.4**
+
+ - [x] 3.3 Implement `TemplateResolver` class in `src/Steergen.Core/Targets/TemplateResolver.cs`
+ - Implement three-level override precedence: local override path > cached GitHub pack > built-in embedded
+ - Implement target-scoped filtering via `declaredTargets` set
+ - Implement `GetTemplate`, `GetTemplateSource`, `ProvidesForTarget` methods
+ - Reject files > 1 MB, do not follow symbolic links, use ordinal file path comparison
+ - Make zero network requests
+ - IF configured `localOverridePath` does not exist on the filesystem, THROW with diagnostic TP001 and exit code 2
+ - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 5.1, 5.2, 5.4, 14.1, 14.2, 14.5, 15.2, 15.3, 15.4, 15.5_
+
+ - [x] 3.4 Write property test for template resolution determinism (Property 6)
+ - **Property 6: Template Resolution Determinism**
+ - Generate random resolver states, call `GetTemplate` twice with same args, assert identical results
+ - Test class: `TemplateResolverProperties` in `tests/Steergen.Core.PropertyTests/Packs/`
+ - **Validates: Requirements 5.1, 5.4**
+
+ - [x] 3.5 Write property test for file size limit enforcement (Property 12)
+ - **Property 12: File Size Limit Enforcement**
+ - Generate random file sizes around the 1 MB boundary; assert rejected if > 1,048,576 bytes, accepted if <=
+ - Test class: `FileSizeLimitProperties` in `tests/Steergen.Core.PropertyTests/Packs/`
+ - **Validates: Requirements 14.2, 14.7**
+
+- [x] 4. Pack downloader and security
+ - [x] 4.1 Implement `GitHubPackSourceParser` in `src/Steergen.Core/Packs/GitHubPackSourceParser.cs`
+ - Parse `github:{owner}/{repo}` format into `GitHubPackSource`
+ - Format `GitHubPackSource` back to canonical string
+ - Return null for invalid formats
+ - _Requirements: 3.1, 10.1_
+
+ - [x] 4.2 Write property test for path traversal rejection (Property 13)
+ - **Property 13: Path Traversal Rejection**
+ - Generate random file paths including `../` sequences; assert rejected if contains traversal or resolves outside pack directory
+ - Test class: `PathTraversalProperties` in `tests/Steergen.Core.PropertyTests/Packs/`
+ - **Validates: Requirements 14.3, 14.4**
+
+ - [x] 4.3 Implement `PackDownloader` in `src/Steergen.Core/Packs/PackDownloader.cs`
+ - Download GitHub archive tarballs via unauthenticated public URL (`https://github.com/{owner}/{repo}/archive/{ref}.tar.gz`)
+ - WHEN no `ref` is specified, use `HEAD` as the ref value in the archive URL
+ - Extract to temp directory, validate `pack.yaml` presence, then atomically swap into cache
+ - WHEN a `path` field is specified on the source, extract only the contents of that subdirectory from the archive
+ - Validate no path traversal (`../`) in archive entry paths
+ - Reject entries outside expected directory structure
+ - Implement `IsImmutablePin` (40-char lowercase hex detection)
+ - Implement `GetCachedPath` for cache lookup
+ - Preserve existing cache on download failure
+ - _Requirements: 3.2, 3.3, 3.5, 3.6, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 9.5, 14.3, 14.4_
+
+ - [x] 4.4 Write unit tests for `PackDownloader` HTTP interactions
+ - Mock `HttpClient` for success/failure scenarios
+ - Test atomic replacement behaviour
+ - Test immutable pin skip logic
+ - Test that HTTP error responses produce DL001 diagnostic with HTTP status code and repository URL
+ - Test default-branch resolution: when `ref` is null, archive URL uses `HEAD`
+ - Test subdirectory extraction: when `path` is specified, only that subdirectory's contents are cached
+ - _Requirements: 3.3, 3.5, 4.4, 4.6, 4.8, 9.5_
+
+- [x] 5. Checkpoint - Ensure all tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+- [x] 6. Configuration model extension and globalRoot retirement
+ - [x] 6.1 Extend `SteeringConfiguration` in `src/Steergen.Core/Model/` with `TemplatePack` and `RulesPacks` fields
+ - Add `TemplatePackConfig` record with `Source`, `Ref`, `LocalPath`
+ - Add `RulesPackEntry` record with `Source`, `Ref`, `Path`, `Scope`
+ - Add `RulesPacks` list to `SteeringConfiguration`
+ - Remove `GlobalRoot` field from `SteeringConfiguration`
+ - _Requirements: 3.1, 8.1, 10.1, 10.2, 10.6_
+
+ - [x] 6.2 Write property test for configuration round-trip (Property 8)
+ - **Property 8: Configuration Round-Trip**
+ - Generate random `SteeringConfiguration` with template pack and rules pack entries; serialize to YAML and deserialize back; assert equivalence
+ - Test class: `ConfigurationProperties` in `tests/Steergen.Core.PropertyTests/Packs/`
+ - **Validates: Requirements 3.1, 10.1, 10.2**
+
+ - [x] 6.3 Implement `globalRoot` deprecation detection in config loader
+ - When `globalRoot` is present in `steergen.config.yaml`, emit diagnostic error CFG001 and exit with code 2
+ - Remove all code paths that discover and load steering documents from a `globalRoot` directory
+ - _Requirements: 8.1, 8.2, 8.3_
+
+ - [x] 6.4 Write unit test for `globalRoot` deprecation error
+ - Verify config with `globalRoot` produces CFG001 diagnostic and exit code 2
+ - _Requirements: 8.2_
+
+- [x] 7. Rules pack loader and merge
+ - [x] 7.1 Create `RulesPackConfiguration`, `RulesPackLoadResult`, and `ScopedPackDocuments` records in `src/Steergen.Core/Packs/`
+ - `RulesPackConfiguration` with `Source` (GitHubPackSource) and `ScopeOverride`
+ - `RulesPackLoadResult` with `Documents` and `Diagnostics`
+ - `ScopedPackDocuments` with `Scope` (PackScope) and `Documents` (IReadOnlyList) — used by extended `SteeringResolver.Resolve` signature
+ - _Requirements: 10.1, 10.6_
+
+ - [x] 7.2 Write property test for rules merge with scope-based precedence (Property 9)
+ - **Property 9: Rules Merge with Scope-Based Precedence**
+ - Generate random rule sets at random scopes with overlapping IDs; assert merge selects highest-precedence source; assert declaration order wins within same scope; assert consumer scope override is respected
+ - Test class: `RulesMergeProperties` in `tests/Steergen.Core.PropertyTests/Packs/`
+ - **Validates: Requirements 10.3, 10.4, 10.5, 10.6, 11.5, 11.7**
+
+ - [x] 7.3 Write property test for rule source tagging (Property 10)
+ - **Property 10: Rule Source Tagging**
+ - Generate random rules from random packs; assert each resolved rule carries correct `SourcePackName` and `SourcePackScope`
+ - Test class: `RulesMergeProperties` in `tests/Steergen.Core.PropertyTests/Packs/`
+ - **Validates: Requirements 11.6**
+
+ - [x] 7.4 Write property test for rules pack file discovery (Property 11)
+ - **Property 11: Rules Pack File Discovery**
+ - Generate random directory trees; assert discovery returns all and only `.md` files recursively in ordinal sort order, excluding symlinks
+ - Test class: `FileDiscoveryProperties` in `tests/Steergen.Core.PropertyTests/Packs/`
+ - **Validates: Requirements 11.1**
+
+ - [x] 7.5 Implement `RulesPackLoader` in `src/Steergen.Core/Packs/RulesPackLoader.cs`
+ - For each configured pack: resolve cache path, parse manifest, validate version compatibility
+ - Determine effective scope (consumer override or manifest scope)
+ - Enumerate `.md` files recursively under rules root (ordinal sort, no symlink follow)
+ - Reject files > 1 MB
+ - Parse each file with `SteeringMarkdownParser`, validate with `SteeringValidator`
+ - Tag each rule with `SourcePackName` and effective scope
+ - Return all documents grouped by effective scope
+ - _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7, 12.2, 13.1, 13.2, 13.3, 14.6, 14.7, 14.8_
+
+ - [x] 7.6 Extend `SteeringResolver.Resolve` to accept `ScopedPackDocuments` and apply merge precedence
+ - Accept rules pack documents with scope metadata alongside project documents
+ - Apply merge order: project-local > project-scoped packs > supplemental > global
+ - Within same scope, earlier declaration order wins
+ - Emit warning diagnostic for duplicate rule IDs at same scope
+ - Extend `SteeringRule` with `SourcePackName` and `SourcePackScope` fields
+ - _Requirements: 10.3, 10.4, 10.5, 11.5, 11.7_
+
+- [x] 8. Checkpoint - Ensure all tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+- [x] 9. External target packs
+ - [x] 9.1 Implement `PackTargetComponent` in `src/Steergen.Core/Targets/PackTargetComponent.cs`
+ - Generic `ITargetComponent` implementation that delegates rendering to pack Scriban templates
+ - Load default layout YAML from pack directory via `LayoutOverrideLoader`
+ - Expose same render model fields as built-in targets: `rules`, `targetId`, `filePath`, `formatOptions`
+ - Use write-plan-driven generation flow identical to built-in targets
+ - _Requirements: 16.3, 16.5, 16.7_
+
+ - [x] 9.2 Write property test for external target registration consistency (Property 14)
+ - **Property 14: External Target Registration Consistency**
+ - Generate random manifests with `providedTargets` and random layout file presence; assert targets available iff `defaultLayout` exists; assert `IsAvailable` correctness
+ - Test class: `TargetRegistryProperties` in `tests/Steergen.Core.PropertyTests/Packs/`
+ - **Validates: Requirements 16.1, 16.3, 16.4, 16.6**
+
+ - [x] 9.3 Write property test for pack-provided target rendering equivalence (Property 15)
+ - **Property 15: Pack-Provided Target Rendering Equivalence**
+ - Generate random rule sets and write plans; assert `PackTargetComponent` produces deterministic output with correct model fields
+ - Test class: `PackTargetComponentProperties` in `tests/Steergen.Core.PropertyTests/Packs/`
+ - **Validates: Requirements 16.5, 16.7**
+
+ - [x] 9.4 Extend `TargetRegistry` with `RegisterPackTargets` and `IsAvailable` in `src/Steergen.Core/Targets/`
+ - Add `GetAvailableTargets()` returning built-in + pack-provided targets
+ - Add `RegisterPackTargets(PackManifest, packBasePath, ITemplateProvider)` to register external targets
+ - Add `IsAvailable(string targetId)` check
+ - Create `TargetDescriptor` and `TargetOrigin` types
+ - Validate `defaultLayout` file exists before registering; emit TP009 if missing
+ - _Requirements: 16.1, 16.2, 16.3, 16.4, 16.6, 16.8_
+
+- [x] 10. Checkpoint - Ensure all tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+- [x] 11. CLI commands for template pack management
+ - [x] 11.1 Implement `steergen template-pack add` command in `src/Steergen.Cli/Commands/TemplatePackAddCommand.cs`
+ - Accept `github:{owner}/{repo}` source argument
+ - Accept `--ref {ref}` option for tag/branch/SHA
+ - Accept `--path {localPath}` option for local override
+ - Add template pack source to `steergen.config.yaml` and trigger download
+ - _Requirements: 7.1, 7.2, 7.3_
+
+ - [x] 11.2 Implement `steergen template-pack remove` command in `src/Steergen.Cli/Commands/TemplatePackRemoveCommand.cs`
+ - Remove template pack configuration from `steergen.config.yaml`
+ - _Requirements: 7.4_
+
+ - [x] 11.3 Extend `steergen update` command with `--templates` flag in `src/Steergen.Cli/Commands/UpdateCommand.cs`
+ - Re-download configured template pack from GitHub source
+ - Display pack name, version, and number of template files on success
+ - Report "no template pack configured" and exit 0 if none configured
+ - Respect `--force` flag to override immutable pin skip
+ - _Requirements: 7.5, 7.7, 7.8_
+
+ - [x] 11.4 Extend `steergen inspect` command with `--templates` flag in `src/Steergen.Cli/Commands/InspectCommand.cs`
+ - Display active template resolution chain showing source per template
+ - _Requirements: 7.6_
+
+- [x] 12. CLI commands for rules pack management
+ - [x] 12.1 Implement `steergen rules-pack add` command in `src/Steergen.Cli/Commands/RulesPackAddCommand.cs`
+ - Accept `github:{owner}/{repo}` source argument
+ - Accept `--ref {ref}`, `--path {subdir}`, `--scope {scope}` options
+ - Append rules pack to `rulesPacks` list in config and trigger download
+ - _Requirements: 17.1, 17.2, 17.3, 17.4_
+
+ - [x] 12.2 Implement `steergen rules-pack remove` command in `src/Steergen.Cli/Commands/RulesPackRemoveCommand.cs`
+ - Remove matching rules pack entry from `steergen.config.yaml` by name
+ - _Requirements: 17.5_
+
+ - [x] 12.3 Implement `steergen rules-pack list` command in `src/Steergen.Cli/Commands/RulesPackListCommand.cs`
+ - Display all configured rules packs with source, ref, scope, and cache status
+ - _Requirements: 17.6_
+
+ - [x] 12.4 Extend `steergen update` command with `--rules` flag
+ - Re-download all configured rules packs regardless of cache state
+ - Respect `--force` flag to override immutable pin skip
+ - _Requirements: 17.7_
+
+ - [x] 12.5 Extend `steergen inspect` command with `--rules` flag
+ - Display all configured rules packs with name, version, source, scope, and number of rules loaded
+ - _Requirements: 17.8, 13.4_
+
+- [x] 13. Template pack validation command
+ - [x] 13.1 Extend `steergen validate` to validate template packs
+ - Validate all template files are parseable Scriban templates
+ - Report file path, line number, and error description for syntax errors
+ - Validate template file names match known template names for declared target IDs
+ - Report warning for template files targeting unregistered targets
+ - _Requirements: 6.1, 6.2, 6.3, 6.4_
+
+ - [x] 13.2 Write property test for template pack validation (Property 7)
+ - **Property 7: Template Pack Validation**
+ - Generate random Scriban-like strings; assert valid iff Scriban parser succeeds; assert warning for unknown template names
+ - Test class: `TemplateValidationProperties` in `tests/Steergen.Core.PropertyTests/Packs/`
+ - **Validates: Requirements 6.1, 6.3**
+
+- [x] 14. Pipeline integration and wiring
+ - [x] 14.1 Wire `TemplateResolver` into the generation pipeline replacing direct `EmbeddedTemplateProvider` usage
+ - Update DI composition in `src/Steergen.Cli/Composition/` to construct `TemplateResolver` from config
+ - Ensure default (no-pack) configuration still uses `EmbeddedTemplateProvider` directly via resolver fallback
+ - Wire `PackTargetComponent` for registered external targets
+ - _Requirements: 1.1, 1.4, 5.2, 5.3, 16.5_
+
+ - [x] 14.2 Wire `RulesPackLoader` into the generation pipeline
+ - Load rules packs during `steergen run` before merge step
+ - Feed loaded documents into extended `SteeringResolver.Resolve`
+ - Emit RP005 error if configured pack not in cache
+ - Emit TP007 error if configured template pack not in cache
+ - _Requirements: 5.3, 11.1, 11.5, 12.2, 12.7_
+
+ - [x] 14.3 Wire `PackDownloader` into CLI commands
+ - Inject `PackDownloader` via DI for `template-pack add`, `rules-pack add`, `update --templates`, `update --rules`
+ - Configure `HttpClient` for GitHub archive downloads
+ - Emit diagnostic warnings for branch refs (recommend pinning to SHA/tag)
+ - _Requirements: 3.7, 4.4, 10.8, 12.4_
+
+ - [x] 14.4 Wire `TargetRegistry` extension for `steergen target add` validation
+ - When user runs `steergen target add {targetId}`, verify target is available as built-in or from configured pack's `providedTargets`
+ - Emit TP010 error if pack providing a registered target is removed
+ - _Requirements: 16.6, 16.8_
+
+- [x] 15. Checkpoint - Ensure all tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+- [x] 16. Integration tests
+ - [x] 16.1 Write integration tests for template pack CLI commands
+ - Test `steergen template-pack add/remove` modifies config correctly
+ - Test `steergen update --templates` downloads and caches
+ - Test `steergen run` with template pack produces overridden output
+ - Test `steergen validate` with malformed template pack reports errors
+ - _Requirements: 7.1, 7.4, 7.5, 6.1_
+
+ - [x] 16.2 Write integration tests for rules pack CLI commands
+ - Test `steergen rules-pack add/remove/list` modifies config correctly
+ - Test `steergen update --rules` downloads and caches
+ - Test `steergen run` with rules packs merges rules correctly
+ - _Requirements: 17.1, 17.5, 17.6, 17.7_
+
+ - [x] 16.3 Write integration test for globalRoot deprecation
+ - Test `steergen run` with `globalRoot` in config fails with CFG001 and exit code 2
+ - _Requirements: 8.2_
+
+ - [x] 16.4 Write integration tests for external target packs
+ - Test `steergen target add` with pack-provided target succeeds
+ - Test `steergen run` with external target renders via pack templates
+ - Test removal of pack providing registered target emits TP010
+ - _Requirements: 16.3, 16.5, 16.6, 16.8_
+
+ - [x] 16.5 Write security integration tests
+ - Test archives with path traversal entries are rejected
+ - Test template files > 1 MB are rejected
+ - Test symlinks in pack directories are not followed
+ - _Requirements: 14.2, 14.3, 14.4, 14.5_
+
+- [x] 17. Final checkpoint - Ensure all tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+- [x] 18. Documentation and migration guidance
+ - [x] 18.1 Update README with template pack and rules pack usage documentation
+ - Add section on configuring template packs (local and GitHub)
+ - Add section on configuring rules packs with scope-based precedence
+ - Document new CLI commands: `template-pack add/remove`, `rules-pack add/remove/list`
+ - Document `steergen update --templates` and `steergen update --rules`
+ - Document `steergen inspect --templates` and `steergen inspect --rules`
+ - _Requirements: Req 7, 10, 17 (CLI surface documentation)_
+
+ - [x] 18.2 Write migration guide for `globalRoot` removal
+ - Document that `globalRoot` is removed and replaced by rules packs
+ - Provide step-by-step migration: convert existing global rules directory to a rules pack with `scope: global`
+ - Include example `pack.yaml` for a migrated global rules directory
+ - Document the CFG001 error and remediation steps
+ - _Requirements: 8.1, 8.2_
+
+ - [x] 18.3 Document error codes and diagnostics
+ - Document all new diagnostic codes (TP001–TP011, RP001–RP007, DL001–DL004, CFG001)
+ - Include remediation guidance for each error
+ - _Requirements: All error-producing requirements_
+
+- [x] 19. Security analysis
+ - [x] 19.1 Produce explicit misuse and abuse analysis document
+ - Analyse prompt-injection-style payloads in template content and rule documents
+ - Analyse path traversal attack vectors in downloaded archives
+ - Analyse symlink-based escape attempts in pack directories
+ - Analyse denial-of-service via oversized files
+ - Analyse supply-chain risks from unauthenticated GitHub downloads (pack substitution, typosquatting)
+ - Document mitigations implemented (Scriban sandboxing, size limits, symlink rejection, path validation, atomic replacement)
+ - _Requirements: 14.1–14.8_
+
+## Notes
+
+- Property-based tests are NON-NEGOTIABLE per constitution and must be implemented before their corresponding implementation tasks (Red-Green-Refactor)
+- Each task references specific requirements for traceability
+- Checkpoints ensure incremental validation
+- Property tests validate universal correctness properties from the design document
+- Unit tests validate specific examples and edge cases where PBT is not practical
+- The project uses .NET 10, C# 14, CsCheck for PBT, xUnit for test framework, NSubstitute for mocking
+- All property tests go in `tests/Steergen.Core.PropertyTests/Packs/` directory
+- Unit tests go in `tests/Steergen.Core.UnitTests/`
+- Integration tests go in `tests/Steergen.Cli.IntegrationTests/`
+- Minimum 100 iterations per property test
+
+## Task Dependency Graph
+
+```json
+{
+ "waves": [
+ { "id": 0, "tasks": ["1.1", "3.1", "4.1"] },
+ { "id": 1, "tasks": ["1.2", "1.3", "1.4", "1.5", "1.6", "4.2"] },
+ { "id": 2, "tasks": ["3.2", "3.3", "3.5", "4.3", "6.1"] },
+ { "id": 3, "tasks": ["3.4", "4.4", "6.2", "6.3", "7.1"] },
+ { "id": 4, "tasks": ["6.4", "7.2", "7.3", "7.4", "7.5"] },
+ { "id": 5, "tasks": ["7.6", "9.1", "9.2"] },
+ { "id": 6, "tasks": ["9.3", "9.4", "13.2"] },
+ { "id": 7, "tasks": ["11.1", "11.2", "12.1", "12.2", "12.3", "13.1"] },
+ { "id": 8, "tasks": ["11.3", "11.4", "12.4", "12.5"] },
+ { "id": 9, "tasks": ["14.1", "14.2", "14.3", "14.4"] },
+ { "id": 10, "tasks": ["16.1", "16.2", "16.3", "16.4", "16.5"] },
+ { "id": 11, "tasks": ["18.1", "18.2", "18.3", "19.1"] }
+ ]
+}
+```
diff --git a/README.md b/README.md
index deda6cd..e2d86e5 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,9 @@ Steergen is a .NET CLI tool that maintains a single set of steering and constitu
- [Supported Targets](#supported-targets)
- [Command Reference](#command-reference)
- [Configuration](#configuration)
+- [Template Packs](#template-packs)
+- [Rules Packs](#rules-packs)
+- [Authoring a Rules Pack](docs/authoring-rules-packs.md)
- [Exit Codes](#exit-codes)
- [Contributing](#contributing)
- [Troubleshooting](#troubleshooting)
@@ -118,18 +121,22 @@ steergen target remove kiro
|---|---|
| `steergen init [root] [--target ...]` | Bootstrap config and target folders |
| `steergen run [options]` | Generate output files for all registered targets |
-| `steergen validate [options]` | Validate source documents; exits non-zero on errors |
-| `steergen inspect [options]` | Print the resolved steering model as JSON |
-| `steergen target add ` | Register a new target |
+| `steergen validate [options]` | Validate source documents and template packs |
+| `steergen inspect [--templates] [--rules]` | Print resolved model, template chain, or rules pack info |
+| `steergen target add ` | Register a new target (built-in or pack-provided) |
| `steergen target remove ` | Unregister a target |
| `steergen purge [options]` | Remove generated files managed by steergen |
-| `steergen update [--version ] [--preview]` | Update `templatePackVersion` in the config file |
+| `steergen update [--templates] [--rules] [--force]` | Re-download configured packs |
+| `steergen template-pack add [--ref [] [--path ]` | Add a template pack |
+| `steergen template-pack remove` | Remove the configured template pack |
+| `steergen rules-pack add [--ref ][] [--path ] [--scope ]` | Add a rules pack |
+| `steergen rules-pack remove ` | Remove a rules pack by name |
+| `steergen rules-pack list` | List configured rules packs with status |
**Commonly used `run` options:**
```
--config Path to steergen.config.yaml
---global Override globalRoot
--project Override projectRoot
--output Override generationRoot
--target Generate for one target only (repeatable)
@@ -146,7 +153,6 @@ Steergen looks for `steergen.config.yaml` in the current directory (or the path
A minimal config file:
```yaml
-globalRoot: steering/global
projectRoot: steering/project
generationRoot: .
registeredTargets:
@@ -154,21 +160,212 @@ registeredTargets:
- copilot-agent
```
+A config with template pack and rules packs:
+
+```yaml
+projectRoot: steering/project
+generationRoot: .
+
+templatePack:
+ source: "github:acme-corp/steergen-templates"
+ ref: "v2.1.0"
+
+rulesPacks:
+ - source: "github:acme-corp/baseline-rules"
+ ref: "abc123def456789012345678901234567890abcd"
+ scope: global
+ - source: "github:acme-corp/team-rules"
+ ref: "v1.0.0"
+ path: "backend-team"
+
+registeredTargets:
+ - kiro
+ - copilot-agent
+```
+
Key fields:
| Field | Purpose |
|---|---|
-| `globalRoot` | Source folder for organisation-wide steering docs |
| `projectRoot` | Source folder for project-specific steering docs |
| `generationRoot` | Base folder for all generated output |
| `registeredTargets` | List of targets to generate by default |
+| `templatePack` | Template pack source configuration (see [Template Packs](#template-packs)) |
+| `rulesPacks` | List of rules pack entries (see [Rules Packs](#rules-packs)) |
| `activeProfiles` | Profile names (legacy; retained for backward compatibility) |
-| `templatePackVersion` | Template pack version (managed by `steergen update`) |
+
+> **Note:** The `globalRoot` field has been removed. If your config still contains `globalRoot`, Steergen will report error CFG001 and exit with code 2. See the [migration guide](docs/migration-globalroot.md) for how to convert existing global rules to a rules pack.
For full configuration options and advanced routing, see [Section 5](docs/getting-started.md#5-controlling-where-generated-files-end-up-roots) and [Section 6](docs/getting-started.md#6-controlling-which-rules-go-where-routing) of the Getting Started guide.
---
+## Template Packs
+
+Template packs let you override the built-in Scriban templates that Steergen uses to render output, or provide complete target definitions for new external targets. Packs can be sourced from a local directory or a public GitHub repository.
+
+### Adding a template pack from GitHub
+
+```bash
+steergen template-pack add github:acme-corp/steergen-templates --ref v2.1.0
+```
+
+This writes the source to `steergen.config.yaml` and downloads the pack to the local cache at `~/.steergen/packs/acme-corp/steergen-templates/v2.1.0/`.
+
+### Adding a local template override path
+
+```bash
+steergen template-pack add --path ./custom-templates
+```
+
+Local overrides take the highest precedence in the resolution chain.
+
+### Template resolution precedence
+
+When rendering, Steergen resolves templates in this order:
+
+1. **Local override path** (`templatePack.localPath`) — highest precedence
+2. **Cached GitHub pack** (`templatePack.source`) — middle precedence
+3. **Built-in embedded templates** — fallback
+
+### Updating a template pack
+
+Re-download the configured GitHub pack to pick up changes:
+
+```bash
+steergen update --templates
+```
+
+On success, displays the pack name, version, and number of template files. If the pack is pinned to a 40-character SHA, re-download is skipped unless `--force` is specified:
+
+```bash
+steergen update --templates --force
+```
+
+If no template pack is configured, the command exits with code 0 and reports that no pack source is configured.
+
+### Inspecting the template chain
+
+See which templates come from which source:
+
+```bash
+steergen inspect --templates
+```
+
+Displays the active resolution chain showing the source (local override, cached GitHub pack, or built-in) for each template.
+
+### Removing a template pack
+
+```bash
+steergen template-pack remove
+```
+
+Removes the template pack configuration from `steergen.config.yaml`.
+
+### Configuration reference
+
+```yaml
+templatePack:
+ source: "github:acme-corp/steergen-templates" # GitHub source
+ ref: "v2.1.0" # Tag, branch, or 40-char SHA
+ # OR use a local path instead:
+ # localPath: "./custom-templates"
+```
+
+---
+
+## Rules Packs
+
+Rules packs are shared governance rule sets published to GitHub repositories. They let teams share steering documents across projects without copying files. Each pack declares a scope that determines its merge precedence relative to project-local rules.
+
+> To create and publish your own rules pack, see the **[Authoring a Rules Pack](docs/authoring-rules-packs.md)** guide.
+
+### Adding a rules pack
+
+```bash
+steergen rules-pack add github:acme-corp/baseline-rules --ref v1.0.0 --scope global
+```
+
+Options:
+
+| Option | Purpose |
+|---|---|
+| `--ref ][` | Git tag, branch, or 40-character SHA |
+| `--path ` | Subdirectory within the repo (for multi-pack repos) |
+| `--scope ` | Override the pack's manifest scope (`global`, `supplemental`, or `project`) |
+
+The command appends the pack to the `rulesPacks` list in `steergen.config.yaml` and downloads it to `~/.steergen/rules/{owner}/{repo}/{ref}/`.
+
+### Scope-based merge precedence
+
+When multiple rule sources define the same rule ID, Steergen resolves conflicts using scope-based precedence:
+
+1. **Project-local rules** — highest precedence (your `projectRoot` documents)
+2. **Project-scoped packs** — rules packs with `scope: project`
+3. **Supplemental-scoped packs** — rules packs with `scope: supplemental`
+4. **Global-scoped packs** — rules packs with `scope: global` (lowest precedence)
+
+Within the same scope level, packs declared earlier in the `rulesPacks` list take precedence. Duplicate rule IDs at the same scope emit a diagnostic warning.
+
+The `--scope` option on `rules-pack add` overrides the scope declared in the pack's own manifest, letting consumers elevate or demote a pack's precedence.
+
+### Listing configured rules packs
+
+```bash
+steergen rules-pack list
+```
+
+Displays all configured rules packs with their source, ref, scope, and cache status.
+
+### Updating rules packs
+
+Re-download all configured rules packs:
+
+```bash
+steergen update --rules
+```
+
+SHA-pinned packs are skipped unless `--force` is specified:
+
+```bash
+steergen update --rules --force
+```
+
+### Inspecting rules packs
+
+```bash
+steergen inspect --rules
+```
+
+Displays all configured rules packs with their name, version, source, scope, and number of rules loaded.
+
+### Removing a rules pack
+
+```bash
+steergen rules-pack remove acme-baseline-rules
+```
+
+Removes the matching entry from the `rulesPacks` list in `steergen.config.yaml`.
+
+### Configuration reference
+
+```yaml
+rulesPacks:
+ - source: "github:acme-corp/baseline-rules"
+ ref: "abc123def456789012345678901234567890abcd" # Pinned SHA (recommended)
+ scope: global
+ - source: "github:acme-corp/team-rules"
+ ref: "v1.0.0"
+ path: "backend-team" # Subdirectory within repo
+ - source: "github:acme-corp/security-rules"
+ ref: "main" # Branch (pinning recommended)
+ scope: supplemental
+```
+
+> **Tip:** Pin rules packs to a tag or full SHA for deterministic builds. Branch refs work but Steergen will recommend pinning in diagnostic output.
+
+---
+
## Exit Codes
| Code | Meaning |
@@ -207,7 +404,7 @@ Ensure `~/.dotnet/tools` (Linux/macOS) or `%USERPROFILE%\.dotnet\tools` (Windows
Run `steergen validate` first — generation is skipped when source documents contain errors.
**Generated files differ between machines**
-Check that `globalRoot` and `projectRoot` point to the same content on each machine. Use `steergen inspect` to compare the resolved model.
+Check that `projectRoot` points to the same content on each machine and that rules packs are pinned to the same ref. Use `steergen inspect` to compare the resolved model.
**Something else?**
Open an issue at .
diff --git a/docs/authoring-rules-packs.md b/docs/authoring-rules-packs.md
new file mode 100644
index 0000000..125f002
--- /dev/null
+++ b/docs/authoring-rules-packs.md
@@ -0,0 +1,410 @@
+# Authoring a Rules Pack
+
+A rules pack is a collection of steering documents published to a GitHub repository (or stored locally) that can be shared across multiple Steergen projects. This guide covers how to create, structure, and publish a rules pack from scratch.
+
+---
+
+## Contents
+
+1. [What is a rules pack?](#1-what-is-a-rules-pack)
+2. [Directory structure](#2-directory-structure)
+3. [Writing the pack manifest](#3-writing-the-pack-manifest)
+4. [Writing steering documents](#4-writing-steering-documents)
+5. [Choosing a scope](#5-choosing-a-scope)
+6. [Multi-pack repositories](#6-multi-pack-repositories)
+7. [Publishing to GitHub](#7-publishing-to-github)
+8. [Versioning and compatibility](#8-versioning-and-compatibility)
+9. [Testing your pack locally](#9-testing-your-pack-locally)
+10. [Best practices](#10-best-practices)
+
+---
+
+## 1. What is a rules pack?
+
+A rules pack is a directory containing:
+
+- A **`pack.yaml` manifest** declaring metadata (name, version, scope, compatibility)
+- One or more **steering documents** (Markdown files with YAML frontmatter and `:::rule` blocks)
+
+When a consumer adds your pack to their `steergen.config.yaml`, Steergen downloads it to a local cache and merges its rules alongside the consumer's project-local rules during generation. The pack's scope determines its merge precedence.
+
+---
+
+## 2. Directory structure
+
+A minimal rules pack:
+
+```
+my-rules-pack/
+├── pack.yaml # Required manifest
+├── security-rules.md # Steering document
+├── quality-rules.md # Steering document
+└── governance-rules.md # Steering document
+```
+
+With a `rulesRoot` subdirectory:
+
+```
+my-rules-pack/
+├── pack.yaml # Required manifest (rulesRoot: "rules/")
+└── rules/
+ ├── security-rules.md
+ ├── quality-rules.md
+ └── governance/
+ ├── code-review.md
+ └── release-process.md
+```
+
+Steergen discovers all `.md` files recursively under the rules root in deterministic ordinal sort order.
+
+---
+
+## 3. Writing the pack manifest
+
+Every rules pack must have a `pack.yaml` file at its root. This is the manifest that declares the pack's identity and compatibility requirements.
+
+### Required fields
+
+```yaml
+name: "acme-baseline-rules"
+version: "1.0.0"
+minSteergenVersion: "1.5.0"
+scope: global
+```
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `name` | Yes | Unique identifier for the pack. Use kebab-case. |
+| `version` | Yes | Semantic version of the pack (e.g., `1.0.0`, `2.3.1`). |
+| `minSteergenVersion` | Yes | Minimum Steergen version required to load this pack. If the consumer's Steergen is older, loading fails with RP002. |
+| `scope` | Yes | Default merge scope: `global`, `supplemental`, or `project`. |
+
+### Optional fields
+
+| Field | Description |
+|-------|-------------|
+| `rulesRoot` | Subdirectory containing the `.md` steering documents. Defaults to the pack root if omitted. |
+
+### Complete example
+
+```yaml
+name: "acme-engineering-standards"
+version: "2.1.0"
+minSteergenVersion: "1.5.0"
+scope: supplemental
+rulesRoot: "rules/"
+```
+
+---
+
+## 4. Writing steering documents
+
+Steering documents in a rules pack use the same format as project-local documents. Each file is a Markdown document with YAML frontmatter and one or more `:::rule` blocks.
+
+### Document structure
+
+```markdown
+---
+id: security-baseline
+version: "1.0.0"
+title: Security Baseline Rules
+scope: global
+status: active
+---
+
+# Security Baseline Rules
+
+:::rule id="SEC-001" mandatory="true" category="security" tags="auth,access-control"
+All API endpoints must require authentication unless explicitly marked as public
+in the service's API specification.
+:::
+
+:::rule id="SEC-002" mandatory="true" category="security" tags="secrets"
+Secrets and credentials must never be committed to source control. Use environment
+variables or a secrets manager for all sensitive configuration.
+:::
+
+:::rule id="SEC-003" mandatory="false" category="security" tags="encryption"
+Data at rest should be encrypted using AES-256 or equivalent. Exceptions require
+an approved Architecture Decision Record.
+:::
+```
+
+### Frontmatter fields
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `id` | Yes | Unique document identifier within the pack |
+| `version` | No | Document version |
+| `title` | No | Human-readable title |
+| `scope` | No | Document-level scope hint |
+| `status` | No | `active`, `draft`, `deprecated` |
+
+### Rule block attributes
+
+| Attribute | Required | Description |
+|-----------|----------|-------------|
+| `id` | Yes | Unique rule identifier (e.g., `SEC-001`). Must be unique across all documents in the pack. |
+| `mandatory` | No | `"true"` or `"false"`. Indicates whether the rule is mandatory or advisory. |
+| `category` | No | Rule category for routing (e.g., `security`, `quality`, `governance`). |
+| `severity` | No | Rule severity (e.g., `error`, `warning`, `info`). |
+| `domain` | No | Domain classification for routing. |
+| `tags` | No | Comma-separated tags for filtering and routing. |
+| `profiles` | No | Comma-separated profile names. Rule only applies when the profile is active. |
+
+### Rule content
+
+The text between `:::rule` and `:::` is the rule's primary text. Write it as clear, actionable guidance. This text is what appears in the generated output consumed by downstream tools.
+
+---
+
+## 5. Choosing a scope
+
+The scope determines where your pack's rules sit in the merge precedence hierarchy:
+
+| Scope | Precedence | Use case |
+|-------|-----------|----------|
+| `global` | Lowest | Organisation-wide baseline rules that any project can override |
+| `supplemental` | Middle | Team or department rules that override global but yield to project-local |
+| `project` | Highest (same as local) | Shared project-specific rules with the same weight as local documents |
+
+**Merge order:** project-local > project-scoped packs > supplemental-scoped packs > global-scoped packs.
+
+Choose `global` for broad organisational baselines. Choose `supplemental` for team-level standards. Choose `project` only when the pack's rules should have the same authority as the consumer's own project rules.
+
+Consumers can override your declared scope using `--scope` when adding the pack, so the manifest scope is a default recommendation rather than an enforcement.
+
+---
+
+## 6. Multi-pack repositories
+
+A single GitHub repository can host multiple independent rules packs by placing each in its own subdirectory, each with its own `pack.yaml`:
+
+```
+governance-packs/
+├── baseline/
+│ ├── pack.yaml # name: "baseline-rules", scope: global
+│ └── rules.md
+├── backend-team/
+│ ├── pack.yaml # name: "backend-team-rules", scope: supplemental
+│ └── api-standards.md
+└── frontend-team/
+ ├── pack.yaml # name: "frontend-team-rules", scope: supplemental
+ └── ui-standards.md
+```
+
+Consumers reference a specific subdirectory using the `--path` option:
+
+```bash
+steergen rules-pack add github:acme-corp/governance-packs --ref v1.0.0 --path backend-team
+```
+
+Or in configuration:
+
+```yaml
+rulesPacks:
+ - source: "github:acme-corp/governance-packs"
+ ref: "v1.0.0"
+ path: "backend-team"
+```
+
+---
+
+## 7. Publishing to GitHub
+
+Once your pack is ready, push it to a public GitHub repository:
+
+```bash
+cd my-rules-pack
+git init
+git add .
+git commit -m "Initial rules pack release"
+git remote add origin https://github.com/your-org/my-rules-pack.git
+git push -u origin main
+```
+
+Tag a release for consumers to pin to:
+
+```bash
+git tag v1.0.0
+git push --tags
+```
+
+Consumers can then add your pack:
+
+```bash
+steergen rules-pack add github:your-org/my-rules-pack --ref v1.0.0 --scope global
+```
+
+### Pinning recommendations
+
+- **Tags** (e.g., `v1.0.0`) — Recommended for most use cases. Conventionally immutable.
+- **Commit SHAs** (40-character hex) — Strongest guarantee of immutability. Steergen skips re-download for SHA-pinned packs.
+- **Branches** (e.g., `main`) — Works but Steergen emits a warning recommending pinning. Content can change without notice.
+
+---
+
+## 8. Versioning and compatibility
+
+### Pack versioning
+
+Follow semantic versioning for your pack:
+
+- **MAJOR** — Breaking changes (removed rules, changed rule IDs, incompatible restructuring)
+- **MINOR** — New rules added, non-breaking scope changes
+- **PATCH** — Wording fixes, clarifications, metadata updates
+
+### `minSteergenVersion`
+
+Set this to the oldest Steergen version that can correctly load your pack. If your pack uses features introduced in a specific Steergen release, set `minSteergenVersion` to that release.
+
+When a consumer's Steergen version is older than `minSteergenVersion`, loading fails with diagnostic RP002 and a clear error message.
+
+---
+
+## 9. Testing your pack locally
+
+Before publishing, test your pack by adding it to a local project:
+
+**Option A: Reference via local cache simulation**
+
+1. Create the cache directory structure manually:
+ ```bash
+ mkdir -p ~/.steergen/rules/your-org/my-rules-pack/v1.0.0
+ cp -r ./* ~/.steergen/rules/your-org/my-rules-pack/v1.0.0/
+ ```
+
+2. Add to your test project's config:
+ ```yaml
+ rulesPacks:
+ - source: "github:your-org/my-rules-pack"
+ ref: "v1.0.0"
+ scope: global
+ ```
+
+3. Run generation:
+ ```bash
+ steergen run
+ ```
+
+**Option B: Validate documents directly**
+
+Copy your pack's `.md` files into a project's `projectRoot` directory temporarily and run:
+
+```bash
+steergen validate
+```
+
+This validates the document format, frontmatter, and rule block syntax using the same parser that `RulesPackLoader` uses.
+
+**Option C: Inspect merged output**
+
+After setting up the cache (Option A), inspect the merged model:
+
+```bash
+steergen inspect --rules
+```
+
+This shows your pack's name, version, scope, and number of rules loaded.
+
+---
+
+## 10. Best practices
+
+### Naming conventions
+
+- Use kebab-case for pack names: `acme-security-rules`, `backend-team-standards`
+- Use uppercase prefixed IDs for rules: `SEC-001`, `QUAL-003`, `GOV-012`
+- Prefix rule IDs with a short namespace to avoid collisions across packs
+
+### Rule ID uniqueness
+
+Rule IDs must be unique within a pack. When two packs at the same scope declare the same rule ID, the pack listed earlier in the consumer's `rulesPacks` wins and a diagnostic warning is emitted. Use distinctive prefixes to avoid collisions.
+
+### Document organisation
+
+- Group related rules into focused documents (one document per concern area)
+- Keep individual documents under 1 MB (Steergen rejects files exceeding this limit)
+- Use descriptive filenames: `api-security.md`, `code-review-standards.md`
+
+### Scope selection
+
+- Default to `global` for organisation-wide baselines
+- Use `supplemental` for team-specific rules that should override global but not project-local
+- Avoid `project` scope unless the pack is tightly coupled to specific projects
+
+### Security considerations
+
+- Do not include secrets, tokens, or credentials in rule documents
+- Be aware that rule content appears in generated output consumed by AI tools
+- Steergen does not follow symbolic links in pack directories
+- Individual files exceeding 1 MB are rejected
+
+### Compatibility
+
+- Set `minSteergenVersion` conservatively — only bump it when you use features from a newer release
+- Test your pack against the `minSteergenVersion` you declare
+- Document breaking changes in your repository's CHANGELOG
+
+---
+
+## Quick reference: complete example
+
+```
+acme-governance-rules/
+├── pack.yaml
+├── CHANGELOG.md
+├── README.md
+└── rules/
+ ├── security/
+ │ ├── authentication.md
+ │ └── data-protection.md
+ ├── quality/
+ │ ├── testing-standards.md
+ │ └── code-review.md
+ └── operations/
+ ├── observability.md
+ └── incident-response.md
+```
+
+**pack.yaml:**
+
+```yaml
+name: "acme-governance-rules"
+version: "2.0.0"
+minSteergenVersion: "1.5.0"
+scope: global
+rulesRoot: "rules/"
+```
+
+**rules/security/authentication.md:**
+
+```markdown
+---
+id: security-authentication
+version: "2.0.0"
+title: Authentication Standards
+scope: global
+status: active
+---
+
+# Authentication Standards
+
+:::rule id="AUTH-001" mandatory="true" category="security" tags="auth,api"
+All API endpoints must require authentication. Public endpoints must be
+explicitly declared in the service's OpenAPI specification with a
+`security: []` override.
+:::
+
+:::rule id="AUTH-002" mandatory="true" category="security" tags="auth,tokens"
+Authentication tokens must have a maximum lifetime of 1 hour. Refresh
+tokens must have a maximum lifetime of 24 hours. Longer-lived tokens
+require an approved security exception.
+:::
+
+:::rule id="AUTH-003" mandatory="false" category="security" tags="auth,mfa"
+Administrative endpoints should require multi-factor authentication.
+Services handling PII or financial data must require MFA for all
+write operations.
+:::
+```
diff --git a/docs/diagnostics.md b/docs/diagnostics.md
new file mode 100644
index 0000000..fd0b526
--- /dev/null
+++ b/docs/diagnostics.md
@@ -0,0 +1,478 @@
+# Diagnostic Codes
+
+Steergen emits structured diagnostics when configuration, validation, or download issues are detected. Each diagnostic has a unique code, a severity level, and a human-readable message. This document lists all diagnostic codes, their meaning, and how to resolve them.
+
+## Exit Codes
+
+| Code | Meaning |
+|------|---------|
+| `0` | Success |
+| `2` | Configuration or validation error (non-recoverable without user action) |
+
+---
+
+## Template Pack Diagnostics (TP001–TP011)
+
+### TP001 — Template pack path does not exist
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | The `templatePack.localPath` configured in `steergen.config.yaml` does not exist on the filesystem. |
+
+**Remediation:**
+
+1. Verify the path in your `steergen.config.yaml` under `templatePack.localPath`.
+2. Ensure the directory exists and is accessible from the working directory where `steergen` is invoked.
+3. If the path is relative, it is resolved relative to the config file location.
+
+---
+
+### TP002 — Template file exceeds size limit
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | A template file in the configured pack exceeds the 1 MB (1,048,576 bytes) maximum size. |
+
+**Remediation:**
+
+1. Identify the oversized template file from the diagnostic message.
+2. Reduce the file size below 1 MB. Template files should contain Scriban template logic, not large embedded data.
+3. If the file legitimately needs to be large, consider splitting it into multiple templates composed via Scriban `include`.
+
+---
+
+### TP003 — Template file contains Scriban syntax errors
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | A template file in the pack cannot be parsed by the Scriban template engine. |
+
+**Remediation:**
+
+1. Check the file path, line number, and error description in the diagnostic output.
+2. Fix the Scriban syntax error in the template file. Common issues include unclosed `{{` blocks, invalid expressions, and mismatched `if`/`end` pairs.
+3. Run `steergen validate` to confirm the fix.
+
+---
+
+### TP004 — Template pack missing pack.yaml (legacy mode)
+
+| | |
+|---|---|
+| **Severity** | Warning |
+| **Condition** | The configured template pack directory does not contain a `pack.yaml` manifest file. The pack is loaded in legacy mode without version constraints. |
+
+**Remediation:**
+
+1. Add a `pack.yaml` file to the root of your template pack directory:
+
+```yaml
+name: "my-templates"
+version: "1.0.0"
+minSteergenVersion: "1.5.0"
+targets:
+ - kiro
+ - speckit
+```
+
+2. Declaring a manifest enables version compatibility checks and target-scoped filtering.
+
+---
+
+### TP005 — Template pack version incompatible
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | The template pack's `minSteergenVersion` in `pack.yaml` is higher than the running Steergen version. |
+
+**Remediation:**
+
+1. Upgrade Steergen to a version that satisfies the pack's `minSteergenVersion`:
+
+```bash
+dotnet tool update --global aabs.steergen
+```
+
+2. Alternatively, use an older version of the template pack that is compatible with your current Steergen version.
+
+---
+
+### TP006 — Template pack contains files for undeclared target
+
+| | |
+|---|---|
+| **Severity** | Warning |
+| **Condition** | The template pack contains template files organised under a target ID directory that is not listed in the pack's `targets` field in `pack.yaml`. |
+
+**Remediation:**
+
+1. Add the target ID to the `targets` list in `pack.yaml` if the templates are intentional.
+2. Remove the extraneous template files if they are not needed.
+3. If the pack is intended to provide templates for all targets, remove the `targets` field entirely from `pack.yaml`.
+
+---
+
+### TP007 — Configured GitHub pack not in local cache
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | A GitHub-sourced template pack is configured but has not been downloaded to the local cache. |
+
+**Remediation:**
+
+1. Download the template pack:
+
+```bash
+steergen update --templates
+```
+
+2. If the download fails, verify the repository URL and ref are correct in your configuration.
+3. Ensure you have network access to `github.com`.
+
+---
+
+### TP008 — Template pack uses branch ref (recommend pinning)
+
+| | |
+|---|---|
+| **Severity** | Warning |
+| **Condition** | The configured template pack `ref` is a branch name rather than a tag or commit SHA. Branch refs can change over time, leading to non-deterministic template resolution. |
+
+**Remediation:**
+
+1. Pin the template pack to a specific tag or commit SHA for deterministic builds:
+
+```yaml
+templatePack:
+ source: "github:acme-corp/steergen-templates"
+ ref: "v2.1.0" # tag — preferred
+```
+
+Or pin to a full 40-character commit SHA:
+
+```yaml
+templatePack:
+ source: "github:acme-corp/steergen-templates"
+ ref: "abc123def456789012345678901234567890abcd"
+```
+
+---
+
+### TP009 — Provided target's defaultLayout file missing
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | A template pack declares a `providedTargets` entry whose `defaultLayout` file does not exist within the pack directory. |
+
+**Remediation:**
+
+1. Check the `defaultLayout` path in the pack's `pack.yaml` under `providedTargets`.
+2. Ensure the referenced layout YAML file exists at the specified relative path within the pack.
+3. If you are the pack author, create the missing layout file or correct the path.
+
+---
+
+### TP010 — Registered target not available (pack removed)
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | A target listed in `registeredTargets` in `steergen.config.yaml` was provided by a template pack that has been removed. The target is no longer available. |
+
+**Remediation:**
+
+1. Re-add the template pack that provides the target:
+
+```bash
+steergen template-pack add github:owner/repo --ref v1.0.0
+```
+
+2. Or remove the target from `registeredTargets` if it is no longer needed:
+
+```bash
+steergen target remove
+```
+
+---
+
+### TP011 — Target ID already registered (duplicate)
+
+| | |
+|---|---|
+| **Severity** | Warning |
+| **Condition** | A template pack declares a `providedTargets` entry with a target ID that is already registered (either as a built-in target or from another pack). |
+
+**Remediation:**
+
+1. If the pack is intended to override an existing target's templates, use the `targets` field instead of `providedTargets`.
+2. If this is a naming collision, contact the pack author to use a unique target ID.
+3. The first registration wins — the duplicate is ignored.
+
+---
+
+## Rules Pack Diagnostics (RP001–RP007)
+
+### RP001 — Rules pack missing pack.yaml
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | A configured rules pack directory (in the local cache) does not contain a `pack.yaml` manifest file. |
+
+**Remediation:**
+
+1. If you are the pack author, add a `pack.yaml` to the root of your rules pack:
+
+```yaml
+name: "my-rules"
+version: "1.0.0"
+minSteergenVersion: "1.5.0"
+scope: global
+```
+
+2. If you are a consumer, re-download the pack in case the cache is corrupted:
+
+```bash
+steergen update --rules --force
+```
+
+---
+
+### RP002 — Rules pack version incompatible
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | The rules pack's `minSteergenVersion` in `pack.yaml` is higher than the running Steergen version. |
+
+**Remediation:**
+
+1. Upgrade Steergen:
+
+```bash
+dotnet tool update --global aabs.steergen
+```
+
+2. Or use an older version of the rules pack that is compatible with your current Steergen version.
+
+---
+
+### RP003 — Rules pack document fails validation
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | A steering document within the rules pack failed validation (e.g., malformed YAML frontmatter, invalid rule block syntax, missing required fields). |
+
+**Remediation:**
+
+1. Check the diagnostic output for the pack name, file path, and specific validation errors.
+2. If you are the pack author, fix the document using the same format as local steering documents.
+3. If you are a consumer, report the issue to the pack maintainer or pin to a known-good version.
+
+---
+
+### RP004 — Duplicate rule ID across same-scope packs
+
+| | |
+|---|---|
+| **Severity** | Warning |
+| **Condition** | Two or more rules packs at the same scope level declare rules with the same rule ID. The rule from the pack declared earlier in the `rulesPacks` list takes precedence. |
+
+**Remediation:**
+
+1. Review the `rulesPacks` order in `steergen.config.yaml` — earlier entries win within the same scope.
+2. If the duplicate is unintentional, contact the pack authors to resolve the ID collision.
+3. If intentional, reorder the `rulesPacks` list so the preferred pack appears first.
+
+---
+
+### RP005 — Configured rules pack not in local cache
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | A configured rules pack has not been downloaded to the local cache. |
+
+**Remediation:**
+
+1. Download all configured rules packs:
+
+```bash
+steergen update --rules
+```
+
+2. If the download fails, verify the repository URL and ref in your configuration.
+3. Ensure you have network access to `github.com`.
+
+---
+
+### RP006 — Rules pack uses branch ref (recommend pinning)
+
+| | |
+|---|---|
+| **Severity** | Warning |
+| **Condition** | A configured rules pack `ref` is a branch name rather than a tag or commit SHA. Branch refs can change over time, leading to non-deterministic rule resolution. |
+
+**Remediation:**
+
+1. Pin the rules pack to a specific tag or commit SHA:
+
+```yaml
+rulesPacks:
+ - source: "github:acme-corp/baseline-rules"
+ ref: "v1.0.0" # tag — preferred
+```
+
+Or use a full 40-character commit SHA:
+
+```yaml
+rulesPacks:
+ - source: "github:acme-corp/baseline-rules"
+ ref: "abc123def456789012345678901234567890abcd"
+```
+
+---
+
+### RP007 — Rules pack document exceeds size limit
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | A steering document file within the rules pack exceeds the 1 MB (1,048,576 bytes) maximum size. |
+
+**Remediation:**
+
+1. Identify the oversized file from the diagnostic message.
+2. Split large documents into multiple smaller files. Each file should cover a focused set of related rules.
+3. Steering documents should contain rule definitions, not large embedded data.
+
+---
+
+## Download Diagnostics (DL001–DL004)
+
+### DL001 — GitHub repository not accessible
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | The pack download failed due to an HTTP error when accessing the GitHub archive URL. The diagnostic includes the HTTP status code and repository URL. |
+
+**Remediation:**
+
+1. Verify the repository exists and is public: `https://github.com/{owner}/{repo}`
+2. Check your network connectivity to `github.com`.
+3. If the repository is private, note that Steergen only supports public repositories. Private repository access is not supported.
+4. Common HTTP status codes:
+ - `404` — Repository or ref does not exist. Check the owner, repo name, and ref value.
+ - `403` — Rate limited or access denied. Wait and retry, or verify the repository is public.
+ - `5xx` — GitHub server error. Retry after a short delay.
+
+---
+
+### DL002 — Downloaded archive missing pack.yaml
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | The downloaded GitHub archive was extracted successfully but does not contain a `pack.yaml` manifest file at the expected location. The download is discarded. |
+
+**Remediation:**
+
+1. Verify the repository contains a `pack.yaml` at its root (or at the configured `path` subdirectory).
+2. If using a `path` field in your configuration, ensure the subdirectory within the repository contains `pack.yaml`.
+3. Contact the pack author if the manifest is missing from the repository.
+
+---
+
+### DL003 — Archive contains path traversal sequences
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | The downloaded archive contains file entries with path traversal sequences (`../`) that would place files outside the expected pack directory. The archive is rejected for security reasons. |
+
+**Remediation:**
+
+1. Do not use this pack — it may be malicious or corrupted.
+2. Report the issue to the pack maintainer.
+3. If you control the repository, ensure no files or directory names contain `../` sequences.
+
+---
+
+### DL004 — Archive contains files outside expected structure
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | The downloaded archive contains file entries that resolve to paths outside the expected pack directory structure (e.g., absolute paths or entries escaping the root). The archive is rejected for security reasons. |
+
+**Remediation:**
+
+1. Do not use this pack — it may be malicious or corrupted.
+2. Report the issue to the pack maintainer.
+3. If you control the repository, ensure all files are contained within the repository root without absolute paths.
+
+---
+
+## Configuration Diagnostics (CFG001)
+
+### CFG001 — Deprecated globalRoot field present
+
+| | |
+|---|---|
+| **Severity** | Error |
+| **Exit code** | 2 |
+| **Condition** | The `steergen.config.yaml` file contains a `globalRoot` field. This field has been removed and its functionality is replaced by rules packs with `scope: global`. |
+
+**Remediation:**
+
+1. Remove the `globalRoot` field from `steergen.config.yaml`.
+2. Convert your existing global rules directory into a rules pack:
+
+ a. Add a `pack.yaml` to the root of your global rules directory:
+
+ ```yaml
+ name: "my-global-rules"
+ version: "1.0.0"
+ minSteergenVersion: "1.5.0"
+ scope: global
+ ```
+
+ b. Publish the directory as a GitHub repository (or use it as a local path).
+
+ c. Add the rules pack to your configuration:
+
+ ```yaml
+ rulesPacks:
+ - source: "github:your-org/global-rules"
+ ref: "v1.0.0"
+ scope: global
+ ```
+
+3. Run `steergen update --rules` to download the pack.
+4. Run `steergen run` to verify generation produces the expected output.
+
+See the [migration guide](./migration-globalroot.md) for detailed step-by-step instructions.
diff --git a/docs/migration/globalroot-removal.md b/docs/migration/globalroot-removal.md
new file mode 100644
index 0000000..af0d1c7
--- /dev/null
+++ b/docs/migration/globalroot-removal.md
@@ -0,0 +1,190 @@
+# Migration Guide: `globalRoot` Removal
+
+## Summary
+
+The `globalRoot` configuration field has been removed from `steergen.config.yaml`. Its functionality is fully replaced by **rules packs** with `scope: global`.
+
+If your configuration still contains `globalRoot`, Steergen will emit diagnostic error **CFG001** and exit with code 2. This guide walks you through converting your existing global rules directory into a rules pack.
+
+## Why Was `globalRoot` Removed?
+
+The `globalRoot` mechanism was a filesystem path coupling that:
+
+- Did not support versioning
+- Could not be shared across teams without manual file copying
+- Had no scope-based precedence model
+- Required every developer to maintain the same local directory structure
+
+Rules packs solve all of these problems. They support versioning via Git refs, team sharing via GitHub repositories, and explicit scope-based merge precedence.
+
+## The CFG001 Error
+
+When `globalRoot` is present in your `steergen.config.yaml`, Steergen emits:
+
+```
+CFG001 [Error]: The 'globalRoot' configuration field has been removed.
+Use rules packs with 'scope: global' instead.
+See migration guide: https://github.com/aabs/steergen/docs/migration/globalroot-removal.md
+```
+
+Steergen exits with code 2 and does not proceed with generation.
+
+### Remediation
+
+Remove the `globalRoot` field from your configuration and follow the migration steps below to convert your global rules directory into a rules pack.
+
+## Step-by-Step Migration
+
+### 1. Locate Your Global Rules Directory
+
+Find the path referenced by `globalRoot` in your current `steergen.config.yaml`:
+
+```yaml
+# Before (no longer supported)
+globalRoot: /path/to/shared/governance-rules
+projectRoot: ./steering
+```
+
+### 2. Create a `pack.yaml` Manifest
+
+In the root of your global rules directory, create a `pack.yaml` file:
+
+```yaml
+# /path/to/shared/governance-rules/pack.yaml
+name: "my-org-baseline-rules"
+version: "1.0.0"
+minSteergenVersion: "1.5.0"
+scope: global
+```
+
+Field reference:
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `name` | Yes | A unique identifier for the pack |
+| `version` | Yes | Semantic version of the pack |
+| `minSteergenVersion` | Yes | Minimum Steergen version required to load this pack |
+| `scope` | Yes | One of `global`, `supplemental`, or `project` |
+| `rulesRoot` | No | Subdirectory containing `.md` rule files (defaults to pack root) |
+
+If your rules are in a subdirectory, add the `rulesRoot` field:
+
+```yaml
+name: "my-org-baseline-rules"
+version: "1.0.0"
+minSteergenVersion: "1.5.0"
+scope: global
+rulesRoot: "rules/"
+```
+
+### 3. Publish to a GitHub Repository (Recommended)
+
+Push your global rules directory to a GitHub repository so it can be shared across projects:
+
+```bash
+cd /path/to/shared/governance-rules
+git init
+git add .
+git commit -m "Initial rules pack"
+git remote add origin https://github.com/my-org/baseline-rules.git
+git push -u origin main
+git tag v1.0.0
+git push --tags
+```
+
+### 4. Update `steergen.config.yaml`
+
+Remove `globalRoot` and add the rules pack to `rulesPacks`:
+
+```yaml
+# After (using GitHub-published rules pack)
+projectRoot: ./steering
+generationRoot: .
+
+rulesPacks:
+ - source: "github:my-org/baseline-rules"
+ ref: "v1.0.0"
+ scope: global
+
+registeredTargets:
+ - kiro
+ - speckit
+```
+
+### 5. Download the Pack
+
+Run the following command to download the rules pack to your local cache:
+
+```bash
+steergen update --rules
+```
+
+Or add it directly via the CLI (which downloads automatically):
+
+```bash
+steergen rules-pack add github:my-org/baseline-rules --ref v1.0.0 --scope global
+```
+
+### 6. Verify
+
+Run generation to confirm everything works:
+
+```bash
+steergen run
+```
+
+## Alternative: Use a Local Path (Development Only)
+
+If you are not ready to publish to GitHub, you can reference the rules pack locally during development by keeping the directory on disk and using `steergen update --rules` after adding it to your config. Note that the pack must still contain a valid `pack.yaml` manifest.
+
+For local-only development workflows, the rules pack cache is located at:
+
+```
+~/.steergen/rules/{owner}/{repo}/{ref}/
+```
+
+## Scope Reference
+
+When migrating from `globalRoot`, use `scope: global` to maintain the same merge precedence behaviour. Global-scoped rules have the lowest precedence and are overridden by project-local rules.
+
+| Scope | Precedence | Use Case |
+|-------|-----------|----------|
+| `global` | Lowest | Organisation-wide baseline rules (replaces `globalRoot`) |
+| `supplemental` | Middle | Team or department rules |
+| `project` | Highest (same as local) | Project-specific shared rules |
+
+Merge order: project-local rules > project-scoped packs > supplemental-scoped packs > global-scoped packs.
+
+## Pinning Recommendations
+
+For deterministic builds, pin your rules pack to a specific Git tag or commit SHA:
+
+```yaml
+# Pinned to tag (recommended)
+rulesPacks:
+ - source: "github:my-org/baseline-rules"
+ ref: "v1.0.0"
+ scope: global
+```
+
+```yaml
+# Pinned to commit SHA (immutable, skips re-download)
+rulesPacks:
+ - source: "github:my-org/baseline-rules"
+ ref: "abc123def456789012345678901234567890abcd"
+ scope: global
+```
+
+Using a branch name (e.g., `main`) works but Steergen will emit a diagnostic warning recommending you pin to a tag or SHA for reproducibility.
+
+## Complete Example: Migrated `pack.yaml`
+
+```yaml
+name: "acme-governance-baseline"
+version: "2.0.0"
+minSteergenVersion: "1.5.0"
+scope: global
+rulesRoot: "rules/"
+```
+
+This manifest declares a rules pack named `acme-governance-baseline` at version `2.0.0`, requiring Steergen 1.5.0 or later, with global scope (lowest merge precedence), and steering documents located in the `rules/` subdirectory.
diff --git a/docs/security/pack-security-analysis.md b/docs/security/pack-security-analysis.md
new file mode 100644
index 0000000..ba785ef
--- /dev/null
+++ b/docs/security/pack-security-analysis.md
@@ -0,0 +1,291 @@
+# Pack Infrastructure Security Analysis
+
+## Overview
+
+This document provides an explicit misuse and abuse analysis for the Custom Template Packs and Rules Packs feature. It covers attack vectors relevant to the pack infrastructure, documents implemented mitigations, and identifies residual risks with recommendations.
+
+**Scope:** Template packs (local and GitHub-sourced), rules packs (GitHub-sourced), pack download and caching, template resolution, and rules loading/merge.
+
+**Requirements coverage:** 14.1–14.8
+
+---
+
+## 1. Template Injection (Prompt-Injection-Style Payloads)
+
+### Attack Vector
+
+A malicious template pack author could craft Scriban template content designed to:
+- Execute arbitrary code on the user's machine during template rendering
+- Exfiltrate data from the rendering context (environment variables, file paths, secrets)
+- Inject malicious content into generated output files that downstream tools interpret as instructions
+
+### Analysis
+
+Scriban is a text templating engine that operates in a sandboxed evaluation model. It does **not** provide:
+- Filesystem access (no `File.Read`, `File.Write`, or equivalent)
+- Process execution (no `System.Diagnostics.Process` or shell invocation)
+- Network access (no HTTP clients or socket operations)
+- Reflection or dynamic type loading
+
+The Scriban engine evaluates expressions against an explicitly constructed render model. Only fields explicitly exposed in the model (`rules`, `targetId`, `filePath`, `formatOptions`) are accessible to template expressions. There is no mechanism for a template to escape the render model boundary.
+
+### Threat: Output Poisoning
+
+A template could generate output that contains prompt-injection payloads targeting downstream AI tools (Kiro, Copilot, etc.). For example, a template could emit steering rules containing `` or similar injection attempts.
+
+**Assessment:** This is a supply-chain trust issue rather than a runtime execution issue. The generated output is deterministic and inspectable. Users can validate output via `steergen validate` and review generated files before committing.
+
+### Mitigations Implemented
+
+| Mitigation | Requirement |
+|------------|-------------|
+| All template content parsed exclusively through Scriban engine — no arbitrary code execution | 14.1 |
+| Render model exposes only declared fields; no ambient environment access | 14.1 |
+| Template validation (`steergen validate`) reports syntax errors with file path and line number | 6.1, 6.2 |
+| File size limit (1 MB) prevents resource exhaustion during parsing | 14.2 |
+
+### Residual Risk
+
+- **Output poisoning:** Low severity. Mitigated by deterministic output and user review. Users should review generated output before committing, especially when adopting new template packs.
+- **Scriban engine vulnerabilities:** Low likelihood. Scriban is a mature, widely-used library. Pinned to version 7.0.6 with vulnerability scanning in CI.
+
+---
+
+## 2. Rule Document Injection
+
+### Attack Vector
+
+A malicious rules pack author could craft Markdown documents with YAML frontmatter designed to:
+- Exploit parser vulnerabilities in the YAML frontmatter parser (YamlDotNet deserialization attacks)
+- Inject content that bypasses validation and produces unexpected merge behaviour
+- Include frontmatter fields that trigger unintended code paths
+
+### Analysis
+
+Rules pack documents are parsed by the existing `SteeringMarkdownParser`, which:
+- Extracts YAML frontmatter using YamlDotNet with a strict, typed deserialization model
+- Parses `:::rule` blocks as structured content with known field schemas
+- Validates all parsed documents through `SteeringValidator` before they enter the merge pipeline
+
+YamlDotNet is configured for safe deserialization — no type discriminators, no arbitrary object instantiation, no `!!python/object` or equivalent unsafe tags. The parser expects specific known fields and ignores unknown fields.
+
+### Mitigations Implemented
+
+| Mitigation | Requirement |
+|------------|-------------|
+| All rule document content parsed exclusively through the existing steering document parser | 14.6 |
+| Strict typed deserialization — no arbitrary object construction from YAML | 14.6 |
+| Full validation via `SteeringValidator` before documents enter the merge pipeline | 11.3 |
+| File size limit (1 MB) on individual steering document files | 14.7 |
+| Diagnostic reporting for validation failures with pack name and file path | 11.4 |
+
+### Residual Risk
+
+- **YamlDotNet vulnerabilities:** Low likelihood. Library is pinned, scanned, and uses safe deserialization patterns.
+- **Semantic injection:** A rules pack could declare rules with IDs that collide with project-local rules, effectively overriding local governance. Mitigated by scope-based precedence (project-local always wins) and duplicate-ID warnings.
+
+---
+
+## 3. Path Traversal in Downloaded Archives
+
+### Attack Vector
+
+A malicious GitHub repository could publish a tarball containing entries with path traversal sequences designed to:
+- Write files outside the expected cache directory (e.g., `../../../.ssh/authorized_keys`)
+- Overwrite system files or other cached packs
+- Place executable files in locations where they would be automatically executed
+
+### Analysis
+
+GitHub archive tarballs (`/archive/{ref}.tar.gz`) contain entries prefixed with `{repo}-{ref}/`. A crafted repository could include files with names like:
+- `../../etc/cron.d/malicious`
+- `repo-main/../../../home/user/.bashrc`
+- Entries with absolute paths (`/etc/passwd`)
+
+### Mitigations Implemented
+
+| Mitigation | Requirement |
+|------------|-------------|
+| All archive entry paths validated for `../` sequences before extraction | 14.3 |
+| Entries resolving outside the expected pack directory structure are rejected | 14.4 |
+| Absolute paths in archive entries are rejected | 14.4 |
+| Extraction to temporary directory with validation before atomic swap into cache | 4.8 |
+| Failed validation discards the entire download — no partial extraction persists | 4.7 |
+
+### Implementation Detail
+
+The `PackDownloader` validates each archive entry path by:
+1. Checking for literal `../` sequences in the entry name
+2. Resolving the full path and verifying it remains within the target extraction directory
+3. Rejecting entries with absolute paths (starting with `/` or drive letter on Windows)
+
+### Residual Risk
+
+- **Platform-specific path tricks:** Windows alternate data streams (`:` in filenames), reserved device names (`CON`, `NUL`). Low severity — these don't escape the cache directory but could cause extraction failures. The atomic replacement pattern ensures partial failures don't corrupt the cache.
+
+---
+
+## 4. Symlink-Based Escape Attempts
+
+### Attack Vector
+
+A malicious pack directory (either local or cached from GitHub) could contain symbolic links designed to:
+- Point template files to sensitive locations outside the pack directory (e.g., `/etc/shadow`, `~/.ssh/id_rsa`)
+- Create circular symlink chains causing infinite loops during file discovery
+- Point to other packs' directories to create confusion about template provenance
+
+### Analysis
+
+Symlinks in pack directories could allow a template resolution or rules file discovery operation to read files outside the intended pack boundary, potentially exposing sensitive data in generated output.
+
+### Mitigations Implemented
+
+| Mitigation | Requirement |
+|------------|-------------|
+| `TemplateResolver` does not follow symbolic links when resolving template files | 14.5 |
+| `RulesPackLoader` does not follow symbolic links when discovering `.md` files | 14.8 |
+| File attributes checked before reading — symlinks are skipped | 14.5, 14.8 |
+| Ordinal file path comparison used for deterministic enumeration (no symlink resolution) | 5.4 |
+
+### Implementation Detail
+
+Both `TemplateResolver` and `RulesPackLoader` check `FileAttributes` for the `ReparsePoint` flag before reading any file. Files identified as symbolic links or junction points are silently skipped during discovery and resolution.
+
+### Residual Risk
+
+- **Hard links:** Hard links cannot be detected via file attributes on all platforms. However, hard links cannot point outside the filesystem volume and cannot reference directories, limiting their attack surface. On extraction from tarballs, hard links within the archive are resolved to regular files.
+- **TOCTOU (time-of-check-time-of-use):** A symlink could theoretically be created between the attribute check and the file read. This requires local filesystem write access to the cache directory, which implies the attacker already has code execution on the machine. Accepted as out-of-scope for this threat model.
+
+---
+
+## 5. Denial-of-Service via Oversized Files
+
+### Attack Vector
+
+A malicious pack could contain:
+- Individual template or rule files exceeding reasonable size, causing memory exhaustion during parsing
+- A large number of small files causing excessive I/O and processing time
+- Compressed archives with high compression ratios (zip bombs / tar bombs) that expand to enormous size on disk
+
+### Analysis
+
+Without size limits, a single multi-gigabyte template file could exhaust process memory during Scriban parsing. A tarball with thousands of files could cause excessive disk I/O during extraction.
+
+### Mitigations Implemented
+
+| Mitigation | Requirement |
+|------------|-------------|
+| Individual template files rejected if > 1,048,576 bytes (1 MB) | 14.2 |
+| Individual steering document files rejected if > 1,048,576 bytes (1 MB) | 14.7 |
+| Size check performed before file content is read into memory | 14.2, 14.7 |
+| Diagnostic error emitted with file path when size limit is exceeded | 14.2, 14.7 |
+
+### Residual Risk
+
+- **Archive decompression bombs:** The current implementation extracts the full archive before validating individual files. A tarball containing many files just under 1 MB each could still consume significant disk space. Mitigation: extraction to a temporary directory with atomic swap means disk space is reclaimed on failure.
+- **File count explosion:** No explicit limit on the number of files in a pack. A pack with thousands of small files would be processed but could be slow. This is a usability issue rather than a security issue — the user chose to configure this pack.
+- **Network bandwidth:** Large archives consume bandwidth during download. Mitigated by caching (download happens once) and immutable SHA pinning (pinned packs skip re-download).
+
+---
+
+## 6. Supply-Chain Risks from Unauthenticated GitHub Downloads
+
+### Attack Vector
+
+The pack download mechanism uses unauthenticated public GitHub archive URLs. This creates several supply-chain risks:
+
+#### 6.1 Pack Substitution
+
+An attacker who gains control of a GitHub repository (compromised credentials, social engineering) could push malicious content that would be downloaded by all consumers on next update.
+
+#### 6.2 Typosquatting
+
+An attacker could create repositories with names similar to legitimate packs (e.g., `acme-corp/steergen-tempaltes` vs `acme-corp/steergen-templates`) hoping users misconfigure their source reference.
+
+#### 6.3 Branch Mutability
+
+When a pack is referenced by branch name (e.g., `ref: main`), the content can change at any time without the consumer's knowledge. A compromised repository could push malicious content to a branch that consumers are tracking.
+
+#### 6.4 No Signature Verification
+
+Downloaded archives are not cryptographically verified. There is no mechanism to confirm that the archive content matches what the repository owner intended to publish.
+
+#### 6.5 Man-in-the-Middle
+
+Although GitHub archive URLs use HTTPS (providing transport encryption), there is no content-level integrity verification beyond what TLS provides.
+
+### Mitigations Implemented
+
+| Mitigation | Description |
+|------------|-------------|
+| SHA pinning detection | 40-character lowercase hex refs are treated as immutable pins, skipping re-download |
+| Pinning recommendation | Diagnostic warning emitted when branch refs are used, recommending SHA or tag pinning |
+| Atomic replacement | Existing cache preserved on download failure — a failed or corrupted download cannot destroy a known-good cached version |
+| Manifest validation | Downloaded archives must contain a valid `pack.yaml` before being committed to cache |
+| Deterministic resolution | No network requests during `steergen run` — only cached content is used at generation time |
+| HTTPS transport | All downloads use `https://github.com/` URLs with TLS verification |
+
+### Recommendations for Users
+
+1. **Pin to commit SHA:** Use full 40-character commit SHAs in `ref` fields for production configurations. This ensures content immutability and prevents silent updates.
+ ```yaml
+ rulesPacks:
+ - source: "github:acme-corp/baseline-rules"
+ ref: "abc123def456789012345678901234567890abcd" # Pinned SHA
+ ```
+
+2. **Review before update:** Run `steergen update --rules` or `steergen update --templates` deliberately, then review changes in generated output before committing.
+
+3. **Verify repository ownership:** Confirm the GitHub repository owner matches the expected organisation before configuring a pack source. Check for typosquatting variants.
+
+4. **Use tags over branches:** When SHA pinning is impractical, prefer version tags (`ref: v1.2.3`) over branch names (`ref: main`). Tags are conventionally immutable (though not enforced by Git).
+
+5. **Audit pack content:** After initial download, inspect the cached pack content at `~/.steergen/packs/` or `~/.steergen/rules/` before running generation.
+
+### Residual Risk
+
+- **No signature verification:** There is no GPG signature or Sigstore verification of pack content. This is a known limitation. Users must rely on GitHub's access controls and their own review processes.
+- **Repository compromise:** If a pack source repository is compromised, pinned SHA refs remain safe (content is immutable), but branch or tag refs could serve malicious content. Mitigation: pinning recommendation and atomic cache preservation.
+- **No content hash in configuration:** The configuration does not store a content hash alongside the ref, so there is no way to detect if a tag was force-pushed to different content. Recommendation: use SHA pinning for critical packs.
+
+---
+
+## 7. Summary of Implemented Mitigations
+
+| Category | Mitigation | Component |
+|----------|-----------|-----------|
+| Template injection | Scriban sandboxed execution — no arbitrary code, no filesystem/network access | `TemplateResolver` |
+| Rule injection | Typed YAML deserialization, full validation pipeline | `RulesPackLoader` |
+| Path traversal | `../` detection, absolute path rejection, boundary validation | `PackDownloader` |
+| Symlink escape | `FileAttributes` check, symlinks not followed | `TemplateResolver`, `RulesPackLoader` |
+| Denial of service | 1 MB file size limit on templates and rule documents | `TemplateResolver`, `RulesPackLoader` |
+| Supply chain | SHA pinning, pinning recommendations, atomic replacement, manifest validation | `PackDownloader` |
+| Cache integrity | Atomic replacement — download to temp, validate, then swap | `PackDownloader` |
+| Determinism | No network requests during generation; cached content only | `TemplateResolver`, `RulesPackLoader` |
+
+---
+
+## 8. Threat Model Summary
+
+| Threat | Likelihood | Impact | Mitigation Effectiveness | Residual Risk |
+|--------|-----------|--------|--------------------------|---------------|
+| Arbitrary code execution via templates | Very Low | Critical | High (Scriban sandbox) | Scriban CVE |
+| Path traversal file write | Low | High | High (multi-layer validation) | Platform-specific edge cases |
+| Symlink-based data exfiltration | Low | Medium | High (attribute check) | Hard links, TOCTOU |
+| Memory exhaustion via large files | Low | Medium | High (1 MB limit) | Archive-level bombs |
+| Repository compromise / substitution | Medium | High | Medium (SHA pinning optional) | Unpinned refs vulnerable |
+| Typosquatting | Low | High | Low (user responsibility) | No automated detection |
+| Output poisoning (prompt injection in generated files) | Medium | Medium | Low (user review) | Requires manual inspection |
+
+---
+
+## 9. Future Considerations
+
+The following enhancements could further reduce residual risk in future versions:
+
+1. **Content hash pinning:** Store a SHA-256 hash of the pack content in configuration alongside the Git ref, enabling detection of force-pushed tags.
+2. **Pack signature verification:** Support Sigstore or GPG signatures on pack manifests, allowing cryptographic verification of pack authorship.
+3. **Archive size limits:** Enforce a maximum total archive size during download to mitigate decompression bombs.
+4. **File count limits:** Enforce a maximum number of files per pack to prevent file-count-based DoS.
+5. **Pack registry:** A curated registry of verified packs with namespace reservation to prevent typosquatting.
+6. **Allowlist/blocklist:** Configuration-level allowlists for permitted pack sources, enabling organisational policy enforcement.
diff --git a/src/Steergen.Cli/Commands/InitCommand.cs b/src/Steergen.Cli/Commands/InitCommand.cs
index 53df5b6..25fcc2b 100644
--- a/src/Steergen.Cli/Commands/InitCommand.cs
+++ b/src/Steergen.Cli/Commands/InitCommand.cs
@@ -65,7 +65,6 @@ public static int RunAsync(string projectRoot, IEnumerable targetIds)
var registeredTargets = targetIds.Distinct(StringComparer.Ordinal).ToList();
var config = new SteeringConfiguration
{
- GlobalRoot = Path.Combine(normalizedProjectRoot, "steering", "global"),
ProjectRoot = Path.Combine(normalizedProjectRoot, "steering", "project"),
RegisteredTargets = registeredTargets,
};
diff --git a/src/Steergen.Cli/Commands/InspectCommand.cs b/src/Steergen.Cli/Commands/InspectCommand.cs
index be2b20d..4124386 100644
--- a/src/Steergen.Cli/Commands/InspectCommand.cs
+++ b/src/Steergen.Cli/Commands/InspectCommand.cs
@@ -1,7 +1,13 @@
using System.CommandLine;
+using System.Reflection;
using Steergen.Core.Configuration;
using Steergen.Core.Merge;
+using Steergen.Core.Model;
+using Steergen.Core.Packs;
using Steergen.Core.Parsing;
+using Steergen.Core.Targets;
+using Steergen.Core.Validation;
+using Steergen.Templates;
namespace Steergen.Cli.Commands;
@@ -32,12 +38,24 @@ public static Command Create()
};
profileOption.Arity = ArgumentArity.ZeroOrMore;
+ var rulesOption = new Option("--rules")
+ {
+ Description = "Display all configured rules packs with name, version, source, scope, and number of rules loaded",
+ };
+
+ var templatesOption = new Option("--templates")
+ {
+ Description = "Display active template resolution chain showing source per template",
+ };
+
var cmd = new Command("inspect", "Inspect the merged steering model as JSON")
{
configOption,
globalOption,
projectOption,
profileOption,
+ rulesOption,
+ templatesOption,
};
cmd.SetAction(async (parseResult, cancellationToken) =>
@@ -46,6 +64,20 @@ public static Command Create()
var globalRoot = parseResult.GetValue(globalOption);
var projectRoot = parseResult.GetValue(projectOption);
var profiles = parseResult.GetValue(profileOption) ?? [];
+ var rules = parseResult.GetValue(rulesOption);
+ var templates = parseResult.GetValue(templatesOption);
+
+ if (templates)
+ {
+ var resolvedConfigPath = ConfigPathResolver.ResolveOptional(parseResult.GetValue(configOption));
+ return await RunTemplatesInspectAsync(resolvedConfigPath, cancellationToken);
+ }
+
+ if (rules)
+ {
+ var resolvedConfigPath = ConfigPathResolver.ResolveRequired(parseResult.GetValue(configOption));
+ return await RunRulesInspectAsync(resolvedConfigPath, cancellationToken);
+ }
return await RunAsync(globalRoot, projectRoot, profiles, configPath, cancellationToken);
});
@@ -75,7 +107,7 @@ public static async Task RunAsync(
config = await loader.LoadAsync(configPath, cancellationToken).ConfigureAwait(false);
}
- globalRoot ??= config?.GlobalRoot;
+ globalRoot ??= null; // globalRoot config field removed; use rules packs instead
projectRoot ??= config?.ProjectRoot;
activeProfiles ??= config?.ActiveProfiles ?? [];
@@ -117,8 +149,271 @@ public static async Task RunAsync(
}
}
+ ///
+ /// Displays all configured rules packs with name, version, source, scope, and number of rules loaded.
+ ///
+ public static async Task RunRulesInspectAsync(
+ string configPath,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (!File.Exists(configPath))
+ {
+ Console.Error.WriteLine($"[error] Config file not found: {configPath}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ var loader = new SteergenConfigLoader();
+ var config = await loader.LoadAsync(configPath, cancellationToken).ConfigureAwait(false);
+
+ if (config.RulesPacks.Count == 0)
+ {
+ Console.WriteLine("No rules packs configured.");
+ return Composition.ExitCodeMapper.Success;
+ }
+
+ var cacheBaseDirectory = GetCacheBaseDirectory();
+ var downloader = new PackDownloader(new HttpClient(), cacheBaseDirectory);
+ var manifestParser = new PackManifestParser();
+ var validator = new SteeringValidator();
+ var rulesPackLoader = new RulesPackLoader(manifestParser, validator);
+ var runningVersion = GetRunningSteergenVersion();
+
+ Console.WriteLine($"{"Name",-25} {"Version",-12} {"Source",-35} {"Scope",-14} {"Rules"}");
+ Console.WriteLine(new string('-', 95));
+
+ foreach (var entry in config.RulesPacks)
+ {
+ var source = GitHubPackSourceParser.Parse(entry.Source, entry.Ref, entry.Path);
+
+ if (source is null)
+ {
+ Console.WriteLine($"{"(invalid)",-25} {"-",-12} {entry.Source,-35} {"-",-14} -");
+ continue;
+ }
+
+ var cachedPath = downloader.GetCachedPath(source, PackType.Rules);
+
+ if (!Directory.Exists(cachedPath))
+ {
+ var scopeDisplay = entry.Scope?.ToString().ToLowerInvariant() ?? "(manifest)";
+ Console.WriteLine($"{"(not cached)",-25} {"-",-12} {entry.Source,-35} {scopeDisplay,-14} -");
+ continue;
+ }
+
+ // Parse manifest to get name and version
+ var manifest = manifestParser.Parse(cachedPath);
+ var packName = manifest?.Name ?? "(unknown)";
+ var packVersion = manifest?.Version ?? "-";
+
+ // Determine effective scope
+ var effectiveScope = entry.Scope ?? manifest?.Scope;
+ var scopeStr = effectiveScope?.ToString().ToLowerInvariant() ?? "(none)";
+
+ // Count rules by loading the pack
+ var packConfig = new RulesPackConfiguration
+ {
+ Source = source,
+ ScopeOverride = entry.Scope,
+ };
+
+ var loadResult = rulesPackLoader.Load([packConfig], cacheBaseDirectory, runningVersion);
+ var ruleCount = loadResult.Documents.Sum(d => d.Rules.Count);
+
+ Console.WriteLine($"{packName,-25} {packVersion,-12} {entry.Source,-35} {scopeStr,-14} {ruleCount}");
+ }
+
+ return Composition.ExitCodeMapper.Success;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[error] {ex.Message}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+ }
+
private static IEnumerable LoadDocuments(string root) =>
Directory.EnumerateFiles(root, "*.md", SearchOption.AllDirectories)
.OrderBy(p => p, StringComparer.Ordinal)
.Select(path => SteeringMarkdownParser.Parse(File.ReadAllText(path), path));
+
+ ///
+ /// Displays the active template resolution chain showing which source provides each template.
+ ///
+ public static async Task RunTemplatesInspectAsync(
+ string? configPath,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ SteeringConfiguration? config = null;
+ if (configPath is not null)
+ {
+ if (!File.Exists(configPath))
+ {
+ Console.Error.WriteLine($"[error] Config file not found: {configPath}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ var loader = new SteergenConfigLoader();
+ config = await loader.LoadAsync(configPath, cancellationToken).ConfigureAwait(false);
+ }
+
+ // Resolve template pack configuration
+ var templatePackConfig = config?.TemplatePack;
+ string? localOverridePath = templatePackConfig?.LocalPath;
+ string? cachedPackPath = null;
+ IReadOnlySet? declaredTargets = null;
+
+ if (templatePackConfig?.Source is not null)
+ {
+ var parsed = GitHubPackSourceParser.Parse(templatePackConfig.Source, templatePackConfig.Ref);
+ if (parsed is not null)
+ {
+ var cacheBase = GetCacheBaseDirectory();
+ var downloader = new PackDownloader(new HttpClient(), cacheBase);
+ cachedPackPath = downloader.GetCachedPath(parsed, PackType.Template);
+
+ // Check if the cache directory actually exists
+ if (!Directory.Exists(cachedPackPath))
+ {
+ cachedPackPath = null;
+ }
+ else
+ {
+ // Try to parse pack manifest for declared targets
+ var manifestParser = new PackManifestParser();
+ var manifest = manifestParser.Parse(cachedPackPath);
+ if (manifest?.Targets is { Count: > 0 } targets)
+ {
+ declaredTargets = new HashSet(targets, StringComparer.Ordinal);
+ }
+ }
+ }
+ }
+
+ var embeddedProvider = new EmbeddedTemplateProvider();
+ var templateResolver = new TemplateResolver(
+ localOverridePath,
+ cachedPackPath,
+ embeddedProvider,
+ declaredTargets);
+
+ // Determine which targets and templates to inspect
+ var templateMap = GetKnownTemplateMap();
+
+ // Display header
+ Console.Out.WriteLine("Template Resolution Chain");
+ Console.Out.WriteLine("=========================");
+ Console.Out.WriteLine();
+
+ // Display configuration summary
+ Console.Out.WriteLine("Configuration:");
+ if (localOverridePath is not null)
+ Console.Out.WriteLine($" Local override path: {localOverridePath}");
+ else
+ Console.Out.WriteLine(" Local override path: (none)");
+
+ if (templatePackConfig?.Source is not null)
+ {
+ Console.Out.WriteLine($" GitHub pack source: {templatePackConfig.Source}");
+ Console.Out.WriteLine($" GitHub pack ref: {templatePackConfig.Ref ?? "(default branch)"}");
+ Console.Out.WriteLine($" Cached pack path: {cachedPackPath ?? "(not cached)"}");
+ }
+ else
+ {
+ Console.Out.WriteLine(" GitHub pack source: (none)");
+ }
+
+ if (declaredTargets is not null)
+ Console.Out.WriteLine($" Declared targets: {string.Join(", ", declaredTargets.OrderBy(t => t, StringComparer.Ordinal))}");
+ else
+ Console.Out.WriteLine(" Declared targets: (all)");
+
+ Console.Out.WriteLine();
+ Console.Out.WriteLine("Resolution per template:");
+ Console.Out.WriteLine("------------------------");
+
+ foreach (var (targetId, templateNames) in templateMap.OrderBy(kv => kv.Key, StringComparer.Ordinal))
+ {
+ Console.Out.WriteLine($" {targetId}/");
+ foreach (var templateName in templateNames.OrderBy(t => t, StringComparer.Ordinal))
+ {
+ var source = templateResolver.GetTemplateSource(targetId, templateName);
+ var sourceLabel = FormatTemplateSource(source);
+ Console.Out.WriteLine($" {templateName}.scriban → {sourceLabel}");
+ }
+ }
+
+ Console.Out.WriteLine();
+ return Composition.ExitCodeMapper.Success;
+ }
+ catch (TemplatePackException ex)
+ {
+ Console.Error.WriteLine($"[{ex.Diagnostic.Severity.ToString().ToLowerInvariant()}] {ex.Diagnostic.Code}: {ex.Diagnostic.Message}");
+ return ex.ExitCode;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[error] Unexpected error: {ex.Message}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+ }
+
+ ///
+ /// Returns the known template names for each built-in target.
+ ///
+ private static Dictionary> GetKnownTemplateMap()
+ {
+ return new Dictionary>(StringComparer.Ordinal)
+ {
+ [TargetRegistry.KnownTargets.Kiro] = ["document"],
+ [TargetRegistry.KnownTargets.Speckit] = ["constitution", "module"],
+ [TargetRegistry.KnownTargets.CopilotAgent] = ["copilot.agent"],
+ [TargetRegistry.KnownTargets.KiroAgent] = ["kiro.agent"],
+ };
+ }
+
+ private static string FormatTemplateSource(TemplateSource source) =>
+ source switch
+ {
+ TemplateSource.LocalOverride => "local override",
+ TemplateSource.CachedGitHubPack => "cached GitHub pack",
+ TemplateSource.BuiltInEmbedded => "built-in embedded",
+ TemplateSource.ProvidedTarget => "provided target pack",
+ _ => "unknown",
+ };
+
+ private static string GetCacheBaseDirectory()
+ {
+ var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ return Path.Combine(userProfile, ".steergen");
+ }
+
+ private static string GetRunningSteergenVersion()
+ {
+ var assembly = typeof(InspectCommand).Assembly;
+ var infoVersion = assembly.GetCustomAttribute()?.InformationalVersion;
+
+ if (infoVersion is not null)
+ {
+ // Strip build metadata (e.g., "+abc123") if present
+ var plusIndex = infoVersion.IndexOf('+');
+ if (plusIndex >= 0)
+ infoVersion = infoVersion[..plusIndex];
+
+ // Strip prerelease suffix (e.g., "-preview1") for semver comparison
+ var dashIndex = infoVersion.IndexOf('-');
+ if (dashIndex >= 0)
+ infoVersion = infoVersion[..dashIndex];
+
+ return infoVersion;
+ }
+
+ var version = assembly.GetName().Version;
+ return version is not null
+ ? $"{version.Major}.{version.Minor}.{version.Build}"
+ : "0.0.0";
+ }
}
diff --git a/src/Steergen.Cli/Commands/PurgeCommand.cs b/src/Steergen.Cli/Commands/PurgeCommand.cs
index fbfeace..28d059b 100644
--- a/src/Steergen.Cli/Commands/PurgeCommand.cs
+++ b/src/Steergen.Cli/Commands/PurgeCommand.cs
@@ -76,7 +76,7 @@ public static async Task RunAsync(
config = await loader.LoadAsync(configPath, cancellationToken);
}
- var resolvedGlobal = config?.GlobalRoot;
+ var resolvedGlobal = (string?)null; // globalRoot config field removed; use rules packs instead
var resolvedProject = config?.ProjectRoot;
var targetIds = explicitTargets.Count > 0
diff --git a/src/Steergen.Cli/Commands/RulesPackAddCommand.cs b/src/Steergen.Cli/Commands/RulesPackAddCommand.cs
new file mode 100644
index 0000000..8c9eaf1
--- /dev/null
+++ b/src/Steergen.Cli/Commands/RulesPackAddCommand.cs
@@ -0,0 +1,175 @@
+using System.CommandLine;
+using Steergen.Core.Configuration;
+using Steergen.Core.Model;
+using Steergen.Core.Packs;
+
+namespace Steergen.Cli.Commands;
+
+///
+/// Implements steergen rules-pack add github:{owner}/{repo}.
+/// Appends a rules pack entry to the rulesPacks list in steergen.config.yaml
+/// and triggers download to the local pack cache.
+/// Accepts --ref for tag/branch/SHA, --path for subdirectory within repo,
+/// and --scope for consumer scope override.
+/// Exits with code 0 (success), 2 (config/IO error), or 5 (optimistic-lock conflict).
+///
+public static class RulesPackAddCommand
+{
+ public static Command Create()
+ {
+ var sourceArg = new Argument("source")
+ {
+ Description = "Rules pack source in the format github:{owner}/{repo}",
+ };
+
+ var refOption = new Option("--ref")
+ {
+ Description = "Git tag, branch, or 40-character commit SHA to pin the rules pack version",
+ };
+
+ var pathOption = new Option("--path")
+ {
+ Description = "Subdirectory within the repository containing the rules pack",
+ };
+
+ var scopeOption = new Option("--scope")
+ {
+ Description = "Scope override for the rules pack (global, supplemental, or project)",
+ };
+
+ var configOption = new Option("--config")
+ {
+ Description = "Path to the steergen config file (default: steergen.config.yaml)",
+ };
+
+ var cmd = new Command("add", "Add a rules pack to the steergen config and download it")
+ {
+ sourceArg,
+ refOption,
+ pathOption,
+ scopeOption,
+ configOption,
+ };
+
+ cmd.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var source = parseResult.GetValue(sourceArg)!;
+ var refValue = parseResult.GetValue(refOption);
+ var path = parseResult.GetValue(pathOption);
+ var scope = parseResult.GetValue(scopeOption);
+ var configPath = ConfigPathResolver.ResolveRequired(parseResult.GetValue(configOption));
+
+ return await ExecuteAsync(configPath, source, refValue, path, scope, cancellationToken);
+ });
+
+ return cmd;
+ }
+
+ public static async Task ExecuteAsync(
+ string configPath,
+ string source,
+ string? refValue,
+ string? path,
+ string? scopeStr,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ // Parse the scope option if provided
+ PackScope? scope = null;
+ if (scopeStr is not null)
+ {
+ if (!TryParseScope(scopeStr, out var parsedScope))
+ {
+ Console.Error.WriteLine($"[error] Invalid scope '{scopeStr}'. Must be one of: global, supplemental, project.");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+ scope = parsedScope;
+ }
+
+ // Parse the GitHub source
+ var parsed = GitHubPackSourceParser.Parse(source, refValue, path);
+ if (parsed is null)
+ {
+ Console.Error.WriteLine($"[error] Invalid source format: '{source}'. Expected format: github:{{owner}}/{{repo}}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ // Trigger download via centralized factory
+ var downloader = Composition.PackDownloaderFactory.Create();
+ var downloadResult = await downloader.DownloadAsync(parsed, PackType.Rules, force: false, cancellationToken);
+
+ if (!downloadResult.Success)
+ {
+ foreach (var diag in downloadResult.Diagnostics)
+ {
+ Console.Error.WriteLine($"[{diag.Severity.ToString().ToLowerInvariant()}] {diag.Code}: {diag.Message}");
+ }
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ // Emit diagnostic warning for branch refs (recommend pinning to SHA/tag)
+ Composition.PackDownloaderFactory.EmitBranchRefWarning(refValue, PackType.Rules);
+
+ // Add to config
+ var entry = new RulesPackEntry
+ {
+ Source = source,
+ Ref = refValue,
+ Path = path,
+ Scope = scope,
+ };
+
+ var svc = new RulesPackRegistrationService();
+ var result = await svc.AddAsync(configPath, entry, cancellationToken);
+
+ if (!result.Success)
+ {
+ Console.Error.WriteLine($"[error] {result.ErrorMessage}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ if (result.WasAlreadyPresent)
+ {
+ Console.Error.WriteLine($"[info] Rules pack '{source}' is already configured (no change).");
+ }
+ else
+ {
+ Console.Error.WriteLine($"[info] Rules pack '{source}' added and downloaded successfully.");
+ }
+
+ return Composition.ExitCodeMapper.Success;
+ }
+ catch (ConfigWriteConflictException ex)
+ {
+ Console.Error.WriteLine($"[conflict] {ex.Message}");
+ return Composition.ExitCodeMapper.ConflictError;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[error] {ex.Message}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+ }
+
+ private static bool TryParseScope(string value, out PackScope scope)
+ {
+ scope = default;
+ if (string.Equals(value, "global", StringComparison.OrdinalIgnoreCase))
+ {
+ scope = PackScope.Global;
+ return true;
+ }
+ if (string.Equals(value, "supplemental", StringComparison.OrdinalIgnoreCase))
+ {
+ scope = PackScope.Supplemental;
+ return true;
+ }
+ if (string.Equals(value, "project", StringComparison.OrdinalIgnoreCase))
+ {
+ scope = PackScope.Project;
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/src/Steergen.Cli/Commands/RulesPackCommand.cs b/src/Steergen.Cli/Commands/RulesPackCommand.cs
new file mode 100644
index 0000000..d9d4bb9
--- /dev/null
+++ b/src/Steergen.Cli/Commands/RulesPackCommand.cs
@@ -0,0 +1,19 @@
+using System.CommandLine;
+
+namespace Steergen.Cli.Commands;
+
+///
+/// Parent command for rules pack management: steergen rules-pack.
+/// Subcommands: list, add, remove.
+///
+public static class RulesPackCommand
+{
+ public static Command Create()
+ {
+ var cmd = new Command("rules-pack", "Manage rules packs in the steergen config");
+ cmd.Add(RulesPackAddCommand.Create());
+ cmd.Add(RulesPackListCommand.Create());
+ cmd.Add(RulesPackRemoveCommand.Create());
+ return cmd;
+ }
+}
diff --git a/src/Steergen.Cli/Commands/RulesPackListCommand.cs b/src/Steergen.Cli/Commands/RulesPackListCommand.cs
new file mode 100644
index 0000000..49586bf
--- /dev/null
+++ b/src/Steergen.Cli/Commands/RulesPackListCommand.cs
@@ -0,0 +1,96 @@
+using System.CommandLine;
+using Steergen.Core.Configuration;
+using Steergen.Core.Packs;
+
+namespace Steergen.Cli.Commands;
+
+///
+/// Implements steergen rules-pack list.
+/// Displays all configured rules packs with their source, ref, scope, and cache status.
+/// Exits with code 0 (success) or 2 (configuration error).
+///
+public static class RulesPackListCommand
+{
+ public static Command Create()
+ {
+ var configOption = new Option("--config")
+ {
+ Description = "Path to steergen.config.yaml (default: steergen.config.yaml in the current directory)",
+ };
+
+ var cmd = new Command("list", "List all configured rules packs with status")
+ {
+ configOption,
+ };
+
+ cmd.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var configPath = ConfigPathResolver.ResolveRequired(parseResult.GetValue(configOption));
+ return await RunAsync(configPath, cancellationToken);
+ });
+
+ return cmd;
+ }
+
+ public static async Task RunAsync(
+ string configPath,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (!File.Exists(configPath))
+ {
+ Console.Error.WriteLine($"[error] Config file not found: {configPath}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ var loader = new SteergenConfigLoader();
+ var config = await loader.LoadAsync(configPath, cancellationToken).ConfigureAwait(false);
+
+ if (config.RulesPacks.Count == 0)
+ {
+ Console.WriteLine("No rules packs configured.");
+ return Composition.ExitCodeMapper.Success;
+ }
+
+ var cacheBaseDirectory = GetCacheBaseDirectory();
+ var downloader = new PackDownloader(new HttpClient(), cacheBaseDirectory);
+
+ Console.WriteLine($"{"Source",-40} {"Ref",-20} {"Scope",-14} {"Cached"}");
+ Console.WriteLine(new string('-', 85));
+
+ foreach (var entry in config.RulesPacks)
+ {
+ var source = GitHubPackSourceParser.Parse(entry.Source, entry.Ref, entry.Path);
+ var refDisplay = entry.Ref ?? "(default)";
+ var scopeDisplay = entry.Scope?.ToString().ToLowerInvariant() ?? "(manifest)";
+
+ string cachedDisplay;
+ if (source is not null)
+ {
+ var cachedPath = downloader.GetCachedPath(source, PackType.Rules);
+ cachedDisplay = Directory.Exists(cachedPath) ? "yes" : "no";
+ }
+ else
+ {
+ cachedDisplay = "invalid source";
+ }
+
+ Console.WriteLine($"{entry.Source,-40} {refDisplay,-20} {scopeDisplay,-14} {cachedDisplay}");
+ }
+
+ return Composition.ExitCodeMapper.Success;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[error] {ex.Message}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+ }
+
+ private static string GetCacheBaseDirectory()
+ {
+ var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ return Path.Combine(userProfile, ".steergen");
+ }
+}
diff --git a/src/Steergen.Cli/Commands/RulesPackRemoveCommand.cs b/src/Steergen.Cli/Commands/RulesPackRemoveCommand.cs
new file mode 100644
index 0000000..199f421
--- /dev/null
+++ b/src/Steergen.Cli/Commands/RulesPackRemoveCommand.cs
@@ -0,0 +1,74 @@
+using System.CommandLine;
+using Steergen.Core.Configuration;
+
+namespace Steergen.Cli.Commands;
+
+///
+/// Implements steergen rules-pack remove {source}.
+/// Removes the matching rules pack entry from steergen.config.yaml by source name.
+/// Exits with code 0 (success), 2 (config/IO error), or 5 (optimistic-lock conflict).
+///
+public static class RulesPackRemoveCommand
+{
+ public static Command Create()
+ {
+ var sourceArg = new Argument("source")
+ {
+ Description = "Source of the rules pack to remove (e.g. github:owner/repo)",
+ };
+ var configOption = new Option("--config")
+ {
+ Description = "Path to the steergen config file (default: steergen.config.yaml)",
+ };
+
+ var cmd = new Command("remove", "Remove a rules pack from the steergen config")
+ {
+ sourceArg,
+ configOption,
+ };
+
+ cmd.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var source = parseResult.GetValue(sourceArg)!;
+ var configPath = ConfigPathResolver.ResolveRequired(parseResult.GetValue(configOption));
+ return await ExecuteAsync(configPath, source, cancellationToken);
+ });
+
+ return cmd;
+ }
+
+ public static async Task ExecuteAsync(
+ string configPath,
+ string source,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var svc = new RulesPackRegistrationService();
+ var result = await svc.RemoveAsync(configPath, source, cancellationToken);
+
+ if (!result.Success)
+ {
+ Console.Error.WriteLine($"[error] {result.ErrorMessage}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ if (result.WasNotPresent)
+ Console.Error.WriteLine($"[info] Rules pack '{source}' was not configured (no change).");
+ else
+ Console.Error.WriteLine($"[info] Rules pack '{source}' removed successfully.");
+
+ return Composition.ExitCodeMapper.Success;
+ }
+ catch (ConfigWriteConflictException ex)
+ {
+ Console.Error.WriteLine($"[conflict] {ex.Message}");
+ return Composition.ExitCodeMapper.ConflictError;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[error] {ex.Message}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+ }
+}
diff --git a/src/Steergen.Cli/Commands/RunCommand.cs b/src/Steergen.Cli/Commands/RunCommand.cs
index 0f3cc12..3e27522 100644
--- a/src/Steergen.Cli/Commands/RunCommand.cs
+++ b/src/Steergen.Cli/Commands/RunCommand.cs
@@ -1,13 +1,16 @@
using System.CommandLine;
+using System.Reflection;
using Steergen.Cli.Diagnostics;
using Steergen.Core.Configuration;
using Steergen.Core.Generation;
using Steergen.Core.Model;
+using Steergen.Core.Packs;
using Steergen.Core.Parsing;
using Steergen.Core.Targets;
using Steergen.Core.Targets.Agents;
using Steergen.Core.Targets.Kiro;
using Steergen.Core.Targets.Speckit;
+using Steergen.Core.Validation;
using Steergen.Templates;
namespace Steergen.Cli.Commands;
@@ -118,18 +121,27 @@ public static async Task RunAsync(
return Composition.ExitCodeMapper.ConfigurationError;
}
var loader = new SteergenConfigLoader();
+
+ // Check for deprecated globalRoot field (CFG001)
+ var deprecationDiag = await loader.CheckForDeprecatedFieldsAsync(configPath, cancellationToken);
+ if (deprecationDiag is not null)
+ {
+ Console.Error.WriteLine($"[error] {deprecationDiag.Code}: {deprecationDiag.Message}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
config = await loader.LoadAsync(configPath, cancellationToken);
}
// Resolve roots: CLI args > config file
- var resolvedGlobal = globalRoot ?? config?.GlobalRoot;
+ var resolvedGlobal = globalRoot; // globalRoot config field removed; use rules packs instead
var resolvedProject = projectRoot ?? config?.ProjectRoot;
var resolvedGenerationRoot = outputBase ?? config?.GenerationRoot ?? defaultOutputPath;
var activeProfiles = config?.ActiveProfiles ?? [];
if (resolvedGlobal is null && resolvedProject is null)
{
- Console.Error.WriteLine("[error] Provide --global and/or --project (or a --config with globalRoot/projectRoot set).");
+ Console.Error.WriteLine("[error] Provide --global and/or --project (or a --config with projectRoot set).");
return Composition.ExitCodeMapper.ConfigurationError;
}
@@ -138,9 +150,71 @@ public static async Task RunAsync(
? explicitTargets
: (IReadOnlyList)(config?.RegisteredTargets ?? []);
- var templateProvider = new EmbeddedTemplateProvider();
+ // Construct the template provider using TemplateResolver with three-level override chain.
+ // When no template pack is configured, localOverridePath and cachedPackPath are null,
+ // so the resolver falls back directly to EmbeddedTemplateProvider.
+ var embeddedProvider = new EmbeddedTemplateProvider();
+ ITemplateProvider templateProvider;
+
+ string? localOverridePath = null;
+ string? cachedPackPath = null;
+ IReadOnlySet? declaredTargets = null;
+ PackManifest? packManifest = null;
+
+ if (config?.TemplatePack is { } templatePackConfig)
+ {
+ // Resolve local override path from config
+ localOverridePath = templatePackConfig.LocalPath;
+ if (localOverridePath is not null && configPath is not null && !Path.IsPathRooted(localOverridePath))
+ {
+ var configDir = Path.GetDirectoryName(Path.GetFullPath(configPath))!;
+ localOverridePath = Path.GetFullPath(Path.Combine(configDir, localOverridePath));
+ }
+
+ // Resolve cached GitHub pack path from config
+ if (templatePackConfig.Source is not null)
+ {
+ var packSource = GitHubPackSourceParser.Parse(
+ templatePackConfig.Source, templatePackConfig.Ref);
+ if (packSource is not null)
+ {
+ var cacheBase = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".steergen");
+ var downloader = new PackDownloader(new HttpClient(), cacheBase);
+ var resolvedCachePath = downloader.GetCachedPath(packSource, PackType.Template);
+
+ if (Directory.Exists(resolvedCachePath))
+ {
+ cachedPackPath = resolvedCachePath;
+
+ // Parse pack manifest to get declared targets
+ var manifestParser = new PackManifestParser();
+ packManifest = manifestParser.Parse(resolvedCachePath);
+ if (packManifest?.Targets is { Count: > 0 } targets)
+ {
+ declaredTargets = new HashSet(targets, StringComparer.Ordinal);
+ }
+ }
+ else
+ {
+ // TP007: configured GitHub pack not in local cache
+ Console.Error.WriteLine(
+ $"[error] TP007: Configured template pack is not in the local cache. " +
+ $"Run 'steergen update --templates' to download it.");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+ }
+ }
+ }
+
+ templateProvider = new TemplateResolver(
+ localOverridePath,
+ cachedPackPath,
+ embeddedProvider,
+ declaredTargets);
- // Build map of all known built-in targets without using the static registry
+ // Build map of all known built-in targets using the resolved template provider
var allComponents = new Dictionary(StringComparer.Ordinal)
{
[TargetRegistry.KnownTargets.Speckit] = new SpeckitTargetComponent(templateProvider),
@@ -148,6 +222,34 @@ public static async Task RunAsync(
[TargetRegistry.KnownTargets.CopilotAgent] = new CopilotAgentTargetComponent(templateProvider),
[TargetRegistry.KnownTargets.KiroAgent] = new KiroAgentTargetComponent(templateProvider),
};
+
+ // Register pack-provided external targets if a template pack manifest declares them
+ if (packManifest?.ProvidedTargets is { Count: > 0 } && cachedPackPath is not null)
+ {
+ var packDiagnostics = TargetRegistry.RegisterPackTargets(
+ packManifest, cachedPackPath, templateProvider);
+
+ foreach (var diag in packDiagnostics)
+ {
+ Console.Error.WriteLine($"[error] {diag.Code}: {diag.Message}");
+ }
+
+ // Add registered pack targets to the available components map
+ foreach (var providedTarget in packManifest.ProvidedTargets)
+ {
+ var layoutPath = Path.Combine(cachedPackPath, providedTarget.DefaultLayout);
+ if (File.Exists(layoutPath) && !allComponents.ContainsKey(providedTarget.TargetId))
+ {
+ allComponents[providedTarget.TargetId] = new PackTargetComponent(
+ providedTarget.TargetId,
+ templateProvider,
+ layoutPath,
+ packManifest.Name,
+ providedTarget.Description);
+ }
+ }
+ }
+
List selectedComponents;
List targetConfigs;
@@ -210,6 +312,72 @@ public static async Task RunAsync(
return Task.FromResult((g, p));
});
+ // Load rules packs before merge step
+ IReadOnlyList? packDocuments = null;
+ if (config?.RulesPacks is { Count: > 0 } rulesPackEntries)
+ {
+ var cacheBase = GetCacheBaseDirectory();
+ var runningVersion = GetRunningSteergenVersion();
+ var manifestParser = new PackManifestParser();
+ var validator = new SteeringValidator();
+ var rulesPackLoader = new RulesPackLoader(manifestParser, validator);
+
+ // Convert RulesPackEntry config entries to RulesPackConfiguration
+ var packConfigs = new List();
+ foreach (var entry in rulesPackEntries)
+ {
+ var source = GitHubPackSourceParser.Parse(entry.Source, entry.Ref, entry.Path);
+ if (source is null)
+ {
+ Console.Error.WriteLine(
+ $"[error] Invalid rules pack source format: '{entry.Source}'");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ packConfigs.Add(new RulesPackConfiguration
+ {
+ Source = source,
+ ScopeOverride = entry.Scope
+ });
+ }
+
+ var loadResult = rulesPackLoader.Load(packConfigs, cacheBase, runningVersion);
+
+ // Check for fatal errors (RP005 = pack not in cache)
+ var fatalErrors = loadResult.Diagnostics
+ .Where(d => d.Severity == DiagnosticSeverity.Error)
+ .ToList();
+
+ if (fatalErrors.Count > 0)
+ {
+ foreach (var diag in fatalErrors)
+ {
+ Console.Error.WriteLine($"[error] {diag.Code}: {diag.Message}");
+ }
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ // Emit non-fatal diagnostics (warnings)
+ foreach (var diag in loadResult.Diagnostics.Where(d => d.Severity != DiagnosticSeverity.Error))
+ {
+ if (!quiet)
+ Console.Error.WriteLine($"[warning] {diag.Code}: {diag.Message}");
+ }
+
+ // Group loaded documents by scope for the extended resolver
+ if (loadResult.Documents.Count > 0)
+ {
+ packDocuments = loadResult.Documents
+ .GroupBy(d => d.Rules.FirstOrDefault()?.SourcePackScope ?? PackScope.Global)
+ .Select(g => new ScopedPackDocuments
+ {
+ Scope = g.Key,
+ Documents = g.ToList()
+ })
+ .ToList();
+ }
+ }
+
var pipeline = new GenerationPipeline();
var result = await reporter.MeasureAsync("run-pipeline", () =>
pipeline.RunAsync(
@@ -221,7 +389,8 @@ public static async Task RunAsync(
cancellationToken,
manifestOutputPath: outputBase,
globalRoot: resolvedGlobal,
- projectRoot: resolvedProject));
+ projectRoot: resolvedProject,
+ packDocuments: packDocuments));
reporter.EmitTotal();
@@ -256,6 +425,11 @@ public static async Task RunAsync(
Console.Error.WriteLine($"[conflict] {ex.Message}");
return Composition.ExitCodeMapper.ConflictError;
}
+ catch (TemplatePackException ex)
+ {
+ Console.Error.WriteLine($"[error] {ex.Diagnostic.Code}: {ex.Diagnostic.Message}");
+ return ex.ExitCode;
+ }
catch (TargetGenerationException ex)
{
Console.Error.WriteLine($"[error] Target generation failed: {ex.Message}");
@@ -307,4 +481,36 @@ private static void EmitRoutingDiagnostics(
}
}
}
+
+ private static string GetCacheBaseDirectory()
+ {
+ var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ return Path.Combine(userProfile, ".steergen");
+ }
+
+ private static string GetRunningSteergenVersion()
+ {
+ var assembly = typeof(RunCommand).Assembly;
+ var infoVersion = assembly.GetCustomAttribute()?.InformationalVersion;
+
+ if (infoVersion is not null)
+ {
+ // Strip build metadata (e.g., "+abc123") if present
+ var plusIndex = infoVersion.IndexOf('+');
+ if (plusIndex >= 0)
+ infoVersion = infoVersion[..plusIndex];
+
+ // Strip prerelease suffix (e.g., "-preview1") for semver comparison
+ var dashIndex = infoVersion.IndexOf('-');
+ if (dashIndex >= 0)
+ infoVersion = infoVersion[..dashIndex];
+
+ return infoVersion;
+ }
+
+ var version = assembly.GetName().Version;
+ return version is not null
+ ? $"{version.Major}.{version.Minor}.{version.Build}"
+ : "0.0.0";
+ }
}
diff --git a/src/Steergen.Cli/Commands/TemplatePackAddCommand.cs b/src/Steergen.Cli/Commands/TemplatePackAddCommand.cs
new file mode 100644
index 0000000..7289f17
--- /dev/null
+++ b/src/Steergen.Cli/Commands/TemplatePackAddCommand.cs
@@ -0,0 +1,171 @@
+using System.CommandLine;
+using Steergen.Core.Configuration;
+using Steergen.Core.Model;
+using Steergen.Core.Packs;
+
+namespace Steergen.Cli.Commands;
+
+///
+/// Implements steergen template-pack add command.
+/// Adds a template pack source to steergen.config.yaml and triggers download.
+/// Accepts github:{owner}/{repo} source argument, --ref for tag/branch/SHA,
+/// and --path for local override.
+/// Exits with code 0 (success), 2 (config/IO error), or 5 (optimistic-lock conflict).
+///
+public static class TemplatePackAddCommand
+{
+ public static Command Create()
+ {
+ var sourceArg = new Argument("source")
+ {
+ Description = "Template pack source in the format github:{owner}/{repo}, or omit when using --path",
+ Arity = ArgumentArity.ZeroOrOne,
+ };
+
+ var refOption = new Option("--ref")
+ {
+ Description = "Git tag, branch, or 40-character commit SHA to pin the template pack version",
+ };
+
+ var pathOption = new Option("--path")
+ {
+ Description = "Local filesystem path to a template pack directory (alternative to GitHub source)",
+ };
+
+ var configOption = new Option("--config")
+ {
+ Description = "Path to the steergen config file (default: steergen.config.yaml)",
+ };
+
+ var cmd = new Command("add", "Add a template pack source to the steergen config and download it")
+ {
+ sourceArg,
+ refOption,
+ pathOption,
+ configOption,
+ };
+
+ cmd.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var source = parseResult.GetValue(sourceArg);
+ var refValue = parseResult.GetValue(refOption);
+ var localPath = parseResult.GetValue(pathOption);
+ var configPath = ConfigPathResolver.ResolveRequired(parseResult.GetValue(configOption));
+
+ return await RunAsync(configPath, source, refValue, localPath, cancellationToken);
+ });
+
+ return cmd;
+ }
+
+ public static async Task RunAsync(
+ string configPath,
+ string? source,
+ string? refValue,
+ string? localPath,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ // Validate that either source or localPath is provided, but not both
+ if (source is null && localPath is null)
+ {
+ Console.Error.WriteLine("[error] Either a github:{owner}/{repo} source argument or --path option is required.");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ if (source is not null && localPath is not null)
+ {
+ Console.Error.WriteLine("[error] Cannot specify both a GitHub source and --path. Use one or the other.");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ // Load existing config
+ var loader = new SteergenConfigLoader();
+ var writer = new SteergenConfigWriter();
+
+ if (!File.Exists(configPath))
+ {
+ Console.Error.WriteLine($"[error] Config file not found: {configPath}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ var bytes = await File.ReadAllBytesAsync(configPath, cancellationToken);
+ var hash = SteergenConfigWriter.ComputeFileHash(bytes);
+ var config = await loader.LoadAsync(configPath, cancellationToken);
+
+ TemplatePackConfig templatePackConfig;
+
+ if (localPath is not null)
+ {
+ // Local path mode
+ templatePackConfig = new TemplatePackConfig
+ {
+ LocalPath = localPath,
+ };
+ }
+ else
+ {
+ // GitHub source mode
+ var parsed = GitHubPackSourceParser.Parse(source!, refValue);
+ if (parsed is null)
+ {
+ Console.Error.WriteLine($"[error] Invalid source format: '{source}'. Expected format: github:{{owner}}/{{repo}}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ templatePackConfig = new TemplatePackConfig
+ {
+ Source = source,
+ Ref = refValue,
+ };
+
+ // Trigger download via centralized factory
+ var downloader = Composition.PackDownloaderFactory.Create();
+ var downloadResult = await downloader.DownloadAsync(parsed, PackType.Template, force: false, cancellationToken);
+
+ if (!downloadResult.Success)
+ {
+ foreach (var diag in downloadResult.Diagnostics)
+ {
+ Console.Error.WriteLine($"[{diag.Severity.ToString().ToLowerInvariant()}] {diag.Code}: {diag.Message}");
+ }
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ // Emit diagnostic warning for branch refs (recommend pinning to SHA/tag)
+ Composition.PackDownloaderFactory.EmitBranchRefWarning(refValue, PackType.Template);
+ }
+
+ // Update config with the new template pack
+ var updated = config with
+ {
+ TemplatePack = templatePackConfig,
+ };
+
+ await writer.WriteAsync(configPath, updated, hash, cancellationToken);
+
+ if (localPath is not null)
+ {
+ Console.Error.WriteLine($"[info] Template pack configured with local path: {localPath}");
+ }
+ else
+ {
+ Console.Error.WriteLine($"[info] Template pack '{source}' added and downloaded successfully.");
+ }
+
+ return Composition.ExitCodeMapper.Success;
+ }
+ catch (ConfigWriteConflictException ex)
+ {
+ Console.Error.WriteLine($"[conflict] {ex.Message}");
+ return Composition.ExitCodeMapper.ConflictError;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[error] {ex.Message}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+ }
+
+}
diff --git a/src/Steergen.Cli/Commands/TemplatePackCommand.cs b/src/Steergen.Cli/Commands/TemplatePackCommand.cs
new file mode 100644
index 0000000..a6eeea7
--- /dev/null
+++ b/src/Steergen.Cli/Commands/TemplatePackCommand.cs
@@ -0,0 +1,17 @@
+using System.CommandLine;
+
+namespace Steergen.Cli.Commands;
+
+///
+/// Parent command for template pack management.
+/// Subcommands: template-pack remove.
+///
+public static class TemplatePackCommand
+{
+ public static Command Create()
+ {
+ var cmd = new Command("template-pack", "Manage the template pack configuration");
+ cmd.Add(TemplatePackRemoveCommand.Create());
+ return cmd;
+ }
+}
diff --git a/src/Steergen.Cli/Commands/TemplatePackRemoveCommand.cs b/src/Steergen.Cli/Commands/TemplatePackRemoveCommand.cs
new file mode 100644
index 0000000..1d421c3
--- /dev/null
+++ b/src/Steergen.Cli/Commands/TemplatePackRemoveCommand.cs
@@ -0,0 +1,66 @@
+using System.CommandLine;
+using Steergen.Core.Configuration;
+
+namespace Steergen.Cli.Commands;
+
+///
+/// Removes the template pack configuration from steergen.config.yaml.
+/// Exits with code 0 (success), 2 (config/IO error), or 5 (optimistic-lock conflict).
+///
+public static class TemplatePackRemoveCommand
+{
+ public static Command Create()
+ {
+ var configOption = new Option("--config")
+ {
+ Description = "Path to steergen.config.yaml (default: steergen.config.yaml in the current directory)",
+ };
+
+ var cmd = new Command("remove", "Remove the template pack configuration from steergen.config.yaml")
+ {
+ configOption,
+ };
+
+ cmd.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var configPath = ConfigPathResolver.ResolveRequired(parseResult.GetValue(configOption));
+ return await RunAsync(configPath, cancellationToken);
+ });
+
+ return cmd;
+ }
+
+ public static async Task RunAsync(
+ string configPath,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var svc = new TemplatePackService();
+ var result = await svc.RemoveAsync(configPath, cancellationToken);
+
+ if (!result.Success)
+ {
+ Console.Error.WriteLine($"[error] {result.ErrorMessage}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ if (result.WasNotConfigured)
+ Console.Error.WriteLine("[info] No template pack is configured (no change).");
+ else
+ Console.Error.WriteLine("[info] Template pack configuration removed.");
+
+ return Composition.ExitCodeMapper.Success;
+ }
+ catch (ConfigWriteConflictException ex)
+ {
+ Console.Error.WriteLine($"[conflict] {ex.Message}");
+ return Composition.ExitCodeMapper.ConflictError;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[error] {ex.Message}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+ }
+}
diff --git a/src/Steergen.Cli/Commands/UpdateCommand.cs b/src/Steergen.Cli/Commands/UpdateCommand.cs
index 0378e22..1c1f1a6 100644
--- a/src/Steergen.Cli/Commands/UpdateCommand.cs
+++ b/src/Steergen.Cli/Commands/UpdateCommand.cs
@@ -1,10 +1,13 @@
using System.CommandLine;
+using Steergen.Core.Configuration;
+using Steergen.Core.Packs;
using Steergen.Core.Updates;
namespace Steergen.Cli.Commands;
///
-/// Updates the template-pack version recorded in the project configuration.
+/// Updates the template-pack version recorded in the project configuration,
+/// and/or re-downloads configured template packs from GitHub sources.
/// Exits with code 0 (success) or 2 (invalid version / config error).
///
public static class UpdateCommand
@@ -26,11 +29,29 @@ public static Command Create()
Description = "Include preview versions when resolving latest",
};
+ var templatesOption = new Option("--templates")
+ {
+ Description = "Re-download the configured template pack from its GitHub source",
+ };
+
+ var rulesOption = new Option("--rules")
+ {
+ Description = "Re-download all configured rules packs from their GitHub sources",
+ };
+
+ var forceOption = new Option("--force")
+ {
+ Description = "Force re-download even when a pack is pinned to an immutable commit SHA",
+ };
+
var cmd = new Command("update", "Update template-pack version in the project configuration")
{
configOption,
versionOption,
previewOption,
+ templatesOption,
+ rulesOption,
+ forceOption,
};
cmd.SetAction(async (parseResult, ct) =>
@@ -38,6 +59,19 @@ public static Command Create()
var configPath = ConfigPathResolver.ResolveRequired(parseResult.GetValue(configOption));
var version = parseResult.GetValue(versionOption);
var preview = parseResult.GetValue(previewOption);
+ var templates = parseResult.GetValue(templatesOption);
+ var rules = parseResult.GetValue(rulesOption);
+ var force = parseResult.GetValue(forceOption);
+
+ if (templates)
+ {
+ return await RunTemplatesUpdateAsync(configPath, force, ct).ConfigureAwait(false);
+ }
+
+ if (rules)
+ {
+ return await RunRulesUpdateAsync(configPath, force, ct).ConfigureAwait(false);
+ }
return await RunAsync(configPath, version, preview, ct).ConfigureAwait(false);
});
@@ -72,4 +106,161 @@ public static async Task RunAsync(
return Composition.ExitCodeMapper.ConfigurationError;
}
}
+
+ ///
+ /// Re-downloads the configured template pack from its GitHub source.
+ /// Displays pack name, version, and number of template files on success.
+ /// Reports "no template pack configured" and exits 0 if none configured.
+ /// Respects to override immutable pin skip.
+ ///
+ public static async Task RunTemplatesUpdateAsync(
+ string configPath,
+ bool force,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (!File.Exists(configPath))
+ {
+ Console.Error.WriteLine($"[error] Config file not found: {configPath}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ var loader = new SteergenConfigLoader();
+ var config = await loader.LoadAsync(configPath, cancellationToken).ConfigureAwait(false);
+
+ // Check if a template pack is configured with a GitHub source
+ if (config.TemplatePack is null || string.IsNullOrWhiteSpace(config.TemplatePack.Source))
+ {
+ Console.Error.WriteLine("[info] No template pack source is configured.");
+ return Composition.ExitCodeMapper.Success;
+ }
+
+ var parsed = GitHubPackSourceParser.Parse(config.TemplatePack.Source, config.TemplatePack.Ref);
+ if (parsed is null)
+ {
+ Console.Error.WriteLine($"[error] Invalid template pack source format: '{config.TemplatePack.Source}'");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ // Download (force bypasses immutable pin skip) via centralized factory
+ var downloader = Composition.PackDownloaderFactory.Create();
+ var downloadResult = await downloader.DownloadAsync(parsed, PackType.Template, force, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!downloadResult.Success)
+ {
+ foreach (var diag in downloadResult.Diagnostics)
+ {
+ Console.Error.WriteLine($"[{diag.Severity.ToString().ToLowerInvariant()}] {diag.Code}: {diag.Message}");
+ }
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ // Parse manifest to get pack name and version
+ var manifestParser = new PackManifestParser();
+ var manifest = manifestParser.Parse(downloadResult.CachePath!);
+
+ var packName = manifest?.Name ?? "unknown";
+ var packVersion = manifest?.Version ?? "unknown";
+
+ // Count template files (.scriban) in the cached pack directory
+ var templateFileCount = Directory.Exists(downloadResult.CachePath)
+ ? Directory.EnumerateFiles(downloadResult.CachePath, "*.scriban", SearchOption.AllDirectories).Count()
+ : 0;
+
+ Console.Error.WriteLine($" updated {packName} v{packVersion} ({templateFileCount} template files)");
+
+ // Emit diagnostic warning for branch refs (recommend pinning to SHA/tag)
+ Composition.PackDownloaderFactory.EmitBranchRefWarning(config.TemplatePack.Ref, PackType.Template);
+
+ return Composition.ExitCodeMapper.Success;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[error] {ex.Message}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+ }
+
+ ///
+ /// Re-downloads all configured rules packs from their GitHub sources.
+ /// Respects to override immutable pin skip.
+ ///
+ public static async Task RunRulesUpdateAsync(
+ string configPath,
+ bool force,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (!File.Exists(configPath))
+ {
+ Console.Error.WriteLine($"[error] Config file not found: {configPath}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
+ var loader = new SteergenConfigLoader();
+ var config = await loader.LoadAsync(configPath, cancellationToken).ConfigureAwait(false);
+
+ if (config.RulesPacks.Count == 0)
+ {
+ Console.Error.WriteLine("[info] No rules packs are configured.");
+ return Composition.ExitCodeMapper.Success;
+ }
+
+ var downloader = Composition.PackDownloaderFactory.Create();
+ var manifestParser = new PackManifestParser();
+ var hasErrors = false;
+
+ foreach (var entry in config.RulesPacks)
+ {
+ var parsed = GitHubPackSourceParser.Parse(entry.Source, entry.Ref, entry.Path);
+ if (parsed is null)
+ {
+ Console.Error.WriteLine($"[error] Invalid rules pack source format: '{entry.Source}'");
+ hasErrors = true;
+ continue;
+ }
+
+ var downloadResult = await downloader.DownloadAsync(parsed, PackType.Rules, force, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!downloadResult.Success)
+ {
+ foreach (var diag in downloadResult.Diagnostics)
+ {
+ Console.Error.WriteLine($"[{diag.Severity.ToString().ToLowerInvariant()}] {diag.Code}: {diag.Message}");
+ }
+ hasErrors = true;
+ continue;
+ }
+
+ // Parse manifest to report pack name and version
+ var manifest = manifestParser.Parse(downloadResult.CachePath!);
+ var packName = manifest?.Name ?? entry.Source;
+ var packVersion = manifest?.Version ?? "unknown";
+
+ // Count rules files (.md) in the cached pack directory
+ var rulesFileCount = Directory.Exists(downloadResult.CachePath)
+ ? Directory.EnumerateFiles(downloadResult.CachePath, "*.md", SearchOption.AllDirectories).Count()
+ : 0;
+
+ Console.Error.WriteLine($" updated {packName} v{packVersion} ({rulesFileCount} rules files)");
+
+ // Emit diagnostic warning for branch refs (recommend pinning to SHA/tag)
+ Composition.PackDownloaderFactory.EmitBranchRefWarning(entry.Ref, PackType.Rules);
+ }
+
+ return hasErrors
+ ? Composition.ExitCodeMapper.ConfigurationError
+ : Composition.ExitCodeMapper.Success;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[error] {ex.Message}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+ }
+
}
diff --git a/src/Steergen.Cli/Commands/ValidateCommand.cs b/src/Steergen.Cli/Commands/ValidateCommand.cs
index a984ef3..5e598f5 100644
--- a/src/Steergen.Cli/Commands/ValidateCommand.cs
+++ b/src/Steergen.Cli/Commands/ValidateCommand.cs
@@ -1,6 +1,7 @@
using System.CommandLine;
using Steergen.Core.Configuration;
using Steergen.Core.Model;
+using Steergen.Core.Packs;
using Steergen.Core.Parsing;
using Steergen.Core.Validation;
@@ -71,10 +72,19 @@ public static async Task RunAsync(
}
var loader = new SteergenConfigLoader();
+
+ // Check for deprecated globalRoot field (CFG001)
+ var deprecationDiag = await loader.CheckForDeprecatedFieldsAsync(configPath, cancellationToken).ConfigureAwait(false);
+ if (deprecationDiag is not null)
+ {
+ Console.Error.WriteLine($"[error] {deprecationDiag.Code}: {deprecationDiag.Message}");
+ return Composition.ExitCodeMapper.ConfigurationError;
+ }
+
config = await loader.LoadAsync(configPath, cancellationToken).ConfigureAwait(false);
}
- globalRoot ??= config?.GlobalRoot;
+ globalRoot ??= null; // globalRoot config field removed; use rules packs instead
projectRoot ??= config?.ProjectRoot;
var allDocuments = new List();
@@ -112,24 +122,14 @@ public static async Task RunAsync(
foreach (var diag in diagnostics)
{
- var severity = diag.Severity switch
- {
- DiagnosticSeverity.Error => "error",
- DiagnosticSeverity.Warning => "warning",
- _ => "info",
- };
-
- if (diag.Severity == DiagnosticSeverity.Error)
- errorCount++;
- else if (diag.Severity == DiagnosticSeverity.Warning)
- warningCount++;
+ ReportDiagnostic(diag, quiet, ref errorCount, ref warningCount);
+ }
- if (diag.Severity == DiagnosticSeverity.Error || !quiet)
- {
- var lineInfo = diag.Location is not null ? $"({diag.Location.LineNumber})" : string.Empty;
- var location = diag.Location is not null ? $"{diag.Location.FilePath}{lineInfo}: " : string.Empty;
- Console.Error.WriteLine($"{location}[{severity}] {diag.Code}: {diag.Message}");
- }
+ // Validate template pack if configured
+ var templatePackDiagnostics = ValidateTemplatePack(config);
+ foreach (var diag in templatePackDiagnostics)
+ {
+ ReportDiagnostic(diag, quiet, ref errorCount, ref warningCount);
}
if (!quiet)
@@ -151,4 +151,125 @@ public static async Task RunAsync(
Directory.EnumerateFiles(root, "*.md", SearchOption.AllDirectories)
.OrderBy(p => p, StringComparer.Ordinal)
.Select(path => SteeringMarkdownParser.Parse(File.ReadAllText(path), path));
+
+ private static void ReportDiagnostic(Diagnostic diag, bool quiet, ref int errorCount, ref int warningCount)
+ {
+ var severity = diag.Severity switch
+ {
+ DiagnosticSeverity.Error => "error",
+ DiagnosticSeverity.Warning => "warning",
+ _ => "info",
+ };
+
+ if (diag.Severity == DiagnosticSeverity.Error)
+ errorCount++;
+ else if (diag.Severity == DiagnosticSeverity.Warning)
+ warningCount++;
+
+ if (diag.Severity == DiagnosticSeverity.Error || !quiet)
+ {
+ var lineInfo = diag.Location is not null ? $"({diag.Location.LineNumber})" : string.Empty;
+ var location = diag.Location is not null ? $"{diag.Location.FilePath}{lineInfo}: " : string.Empty;
+ Console.Error.WriteLine($"{location}[{severity}] {diag.Code}: {diag.Message}");
+ }
+ }
+
+ ///
+ /// Validates a configured template pack: checks all .scriban files are parseable,
+ /// validates template file names match known template names for declared targets,
+ /// and reports warnings for template files targeting unregistered targets.
+ ///
+ private static IReadOnlyList ValidateTemplatePack(SteeringConfiguration? config)
+ {
+ if (config?.TemplatePack is null)
+ return [];
+
+ var packPath = ResolveTemplatePackPath(config.TemplatePack);
+ if (packPath is null)
+ return [];
+
+ if (!Directory.Exists(packPath))
+ return [];
+
+ var templatePackValidator = new TemplatePackValidator();
+ var manifestParser = new PackManifestParser();
+ var diagnostics = new List();
+
+ // Parse the pack manifest to get declared targets
+ var manifest = manifestParser.Parse(packPath);
+ var declaredTargets = manifest?.Targets;
+ var registeredTargets = config.RegisteredTargets;
+
+ // Enumerate all .scriban files in the pack directory (deterministic order)
+ var scribanFiles = Directory.EnumerateFiles(packPath, "*.scriban", SearchOption.AllDirectories)
+ .OrderBy(p => p, StringComparer.Ordinal)
+ .ToList();
+
+ foreach (var filePath in scribanFiles)
+ {
+ // Skip symbolic links
+ var attributes = File.GetAttributes(filePath);
+ if (attributes.HasFlag(FileAttributes.ReparsePoint))
+ continue;
+
+ // Extract target ID and template name from file path
+ // Expected structure: {packPath}/{targetId}/{templateName}.scriban
+ var relativePath = Path.GetRelativePath(packPath, filePath);
+ var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+
+ if (parts.Length < 2)
+ continue; // Skip files not in a target subdirectory
+
+ var targetId = parts[0];
+ var templateName = Path.GetFileNameWithoutExtension(parts[^1]);
+
+ // Validate Scriban syntax (Requirement 6.1, 6.2)
+ var content = File.ReadAllText(filePath);
+ var contentDiagnostics = templatePackValidator.ValidateTemplateContent(content, filePath);
+ diagnostics.AddRange(contentDiagnostics);
+
+ // Validate template name matches known names for the target (Requirement 6.3)
+ var nameDiagnostics = templatePackValidator.ValidateTemplateName(templateName, targetId);
+ diagnostics.AddRange(nameDiagnostics);
+
+ // Report warning for template files targeting unregistered targets (Requirement 6.4)
+ if (registeredTargets.Count > 0 && !registeredTargets.Contains(targetId))
+ {
+ diagnostics.Add(new Diagnostic(
+ "TP006",
+ $"Template file '{relativePath}' targets '{targetId}' which is not a registered target.",
+ DiagnosticSeverity.Warning));
+ }
+ }
+
+ return diagnostics;
+ }
+
+ ///
+ /// Resolves the template pack directory path from configuration.
+ /// Returns the local path if configured, otherwise attempts to resolve the cached GitHub pack path.
+ ///
+ private static string? ResolveTemplatePackPath(TemplatePackConfig templatePack)
+ {
+ // Local path takes precedence
+ if (!string.IsNullOrWhiteSpace(templatePack.LocalPath))
+ return templatePack.LocalPath;
+
+ // GitHub source: resolve to cached pack path
+ if (!string.IsNullOrWhiteSpace(templatePack.Source))
+ {
+ var source = GitHubPackSourceParser.Parse(templatePack.Source, templatePack.Ref);
+ if (source is null)
+ return null;
+
+ var cacheBase = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".steergen");
+
+ var refValue = source.Ref ?? "HEAD";
+ return Path.Combine(cacheBase, "packs", source.Owner, source.Repo, refValue);
+ }
+
+ return null;
+ }
}
diff --git a/src/Steergen.Cli/Composition/CommandFactory.cs b/src/Steergen.Cli/Composition/CommandFactory.cs
index 0578323..b81b469 100644
--- a/src/Steergen.Cli/Composition/CommandFactory.cs
+++ b/src/Steergen.Cli/Composition/CommandFactory.cs
@@ -16,6 +16,8 @@ public static RootCommand CreateRootCommand()
var targetCommand = Commands.TargetCommand.Create();
var purgeCommand = Commands.PurgeCommand.Create();
+ var rulesPackCommand = Commands.RulesPackCommand.Create();
+ var templatePackCommand = Commands.TemplatePackCommand.Create();
rootCommand.Add(runCommand);
rootCommand.Add(validateCommand);
@@ -24,6 +26,8 @@ public static RootCommand CreateRootCommand()
rootCommand.Add(updateCommand);
rootCommand.Add(targetCommand);
rootCommand.Add(purgeCommand);
+ rootCommand.Add(rulesPackCommand);
+ rootCommand.Add(templatePackCommand);
return rootCommand;
}
diff --git a/src/Steergen.Cli/Composition/PackDownloaderFactory.cs b/src/Steergen.Cli/Composition/PackDownloaderFactory.cs
new file mode 100644
index 0000000..b768123
--- /dev/null
+++ b/src/Steergen.Cli/Composition/PackDownloaderFactory.cs
@@ -0,0 +1,87 @@
+using Steergen.Core.Packs;
+using Steergen.Core.Validation;
+
+namespace Steergen.Cli.Composition;
+
+///
+/// Centralised factory for creating instances
+/// with a properly configured for GitHub archive downloads.
+/// Provides shared helper methods for branch-ref diagnostic warnings.
+///
+public static class PackDownloaderFactory
+{
+ private static readonly Lazy SharedHttpClient = new(() =>
+ {
+ var client = new HttpClient();
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("Steergen/1.0");
+ client.Timeout = TimeSpan.FromMinutes(5);
+ return client;
+ });
+
+ ///
+ /// Creates a using the shared
+ /// configured for GitHub archive downloads and the default cache base directory.
+ ///
+ public static PackDownloader Create()
+ {
+ return new PackDownloader(SharedHttpClient.Value, GetCacheBaseDirectory());
+ }
+
+ ///
+ /// Creates a using the shared
+ /// configured for GitHub archive downloads and a custom cache base directory.
+ ///
+ public static PackDownloader Create(string cacheBaseDirectory)
+ {
+ return new PackDownloader(SharedHttpClient.Value, cacheBaseDirectory);
+ }
+
+ ///
+ /// Returns the default local pack cache base directory:
+ /// {userProfileDirectory}/.steergen.
+ ///
+ public static string GetCacheBaseDirectory()
+ {
+ var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ return Path.Combine(userProfile, ".steergen");
+ }
+
+ ///
+ /// Emits a diagnostic warning to stderr if the given ref is a branch ref
+ /// (not an immutable SHA pin and not a likely tag). Returns the diagnostic
+ /// if one was emitted, or null otherwise.
+ ///
+ /// The Git ref value (tag, branch, or SHA).
+ /// The type of pack (Template or Rules) for message context.
+ /// The emitted diagnostic, or null if no warning was needed.
+ public static Diagnostic? EmitBranchRefWarning(string? refValue, PackType packType)
+ {
+ if (refValue is null)
+ return null;
+
+ if (PackDownloader.IsImmutablePin(refValue))
+ return null;
+
+ if (IsLikelyTag(refValue))
+ return null;
+
+ var code = packType == PackType.Template ? "TP008" : "RP006";
+ var context = packType == PackType.Template ? "template" : "rule";
+ var message = $"Using branch ref '{refValue}'. Consider pinning to a commit SHA or tag for deterministic {context} resolution.";
+
+ var diagnostic = new Diagnostic(code, message, DiagnosticSeverity.Warning);
+ Console.Error.WriteLine($"[warning] {code}: {message}");
+ return diagnostic;
+ }
+
+ ///
+ /// Heuristic: a ref that starts with 'v' followed by a digit is likely a tag.
+ /// This is used to suppress the pinning recommendation for tag-like refs.
+ ///
+ internal static bool IsLikelyTag(string refValue)
+ {
+ return refValue.Length > 1
+ && refValue[0] == 'v'
+ && char.IsDigit(refValue[1]);
+ }
+}
diff --git a/src/Steergen.Core/Configuration/LayoutOverrideLoader.cs b/src/Steergen.Core/Configuration/LayoutOverrideLoader.cs
index f655d3e..1f6104d 100644
--- a/src/Steergen.Core/Configuration/LayoutOverrideLoader.cs
+++ b/src/Steergen.Core/Configuration/LayoutOverrideLoader.cs
@@ -73,6 +73,32 @@ public TargetLayoutDefinition LoadDefault(string targetId)
return MapToModel(targetId, dto);
}
+ ///
+ /// Loads a layout definition from a YAML file on disk. Used for pack-provided targets
+ /// whose default layout is not an embedded resource but a file within the pack directory.
+ /// Optionally deep-merges a user-provided override on top.
+ ///
+ public async Task LoadFromFileAsync(
+ string targetId,
+ string layoutFilePath,
+ string? overrideFilePath = null,
+ CancellationToken cancellationToken = default)
+ {
+ var yaml = await File.ReadAllTextAsync(layoutFilePath, cancellationToken)
+ .ConfigureAwait(false);
+ var dto = Deserializer.Deserialize(yaml);
+
+ if (overrideFilePath is not null)
+ {
+ var overrideYaml = await File.ReadAllTextAsync(overrideFilePath, cancellationToken)
+ .ConfigureAwait(false);
+ var overrideDto = Deserializer.Deserialize(overrideYaml);
+ dto = DeepMerge(dto, overrideDto);
+ }
+
+ return MapToModel(targetId, dto);
+ }
+
private static string LoadEmbeddedYaml(string targetId)
{
var resourceName = GetEmbeddedResourceName(targetId);
diff --git a/src/Steergen.Core/Configuration/RulesPackRegistrationService.cs b/src/Steergen.Core/Configuration/RulesPackRegistrationService.cs
new file mode 100644
index 0000000..f849971
--- /dev/null
+++ b/src/Steergen.Core/Configuration/RulesPackRegistrationService.cs
@@ -0,0 +1,102 @@
+using Steergen.Core.Model;
+using Steergen.Core.Packs;
+
+namespace Steergen.Core.Configuration;
+
+///
+/// Adds and removes rules pack entries from the rulesPacks list in a steergen config file.
+/// Uses optimistic locking to detect concurrent modifications.
+///
+public sealed class RulesPackRegistrationService
+{
+ private readonly SteergenConfigLoader _loader = new();
+ private readonly SteergenConfigWriter _writer = new();
+
+ public async Task AddAsync(
+ string configPath,
+ RulesPackEntry entry,
+ CancellationToken cancellationToken = default)
+ {
+ if (!File.Exists(configPath))
+ return RulesPackRegistrationResult.Fail($"Config file not found: {configPath}");
+
+ var (config, hash) = await ReadWithHash(configPath, cancellationToken);
+
+ // Check if already present (same source)
+ var alreadyPresent = config.RulesPacks
+ .Any(r => string.Equals(r.Source, entry.Source, StringComparison.OrdinalIgnoreCase));
+
+ if (alreadyPresent)
+ return RulesPackRegistrationResult.AlreadyPresent(entry.Source);
+
+ var updated = config with
+ {
+ RulesPacks = [.. config.RulesPacks, entry],
+ };
+
+ await _writer.WriteAsync(configPath, updated, hash, cancellationToken);
+ return RulesPackRegistrationResult.Added(entry.Source);
+ }
+
+ public async Task RemoveAsync(
+ string configPath,
+ string source,
+ CancellationToken cancellationToken = default)
+ {
+ if (!File.Exists(configPath))
+ return RulesPackRegistrationResult.Fail($"Config file not found: {configPath}");
+
+ var (config, hash) = await ReadWithHash(configPath, cancellationToken);
+
+ var matching = config.RulesPacks
+ .Where(r => string.Equals(r.Source, source, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (matching.Count == 0)
+ return RulesPackRegistrationResult.NotPresent(source);
+
+ var updated = config with
+ {
+ RulesPacks = config.RulesPacks
+ .Where(r => !string.Equals(r.Source, source, StringComparison.OrdinalIgnoreCase))
+ .ToList(),
+ };
+
+ await _writer.WriteAsync(configPath, updated, hash, cancellationToken);
+ return RulesPackRegistrationResult.Removed(source);
+ }
+
+ private async Task<(SteeringConfiguration Config, string Hash)> ReadWithHash(
+ string configPath,
+ CancellationToken cancellationToken)
+ {
+ var bytes = await File.ReadAllBytesAsync(configPath, cancellationToken);
+ var hash = SteergenConfigWriter.ComputeFileHash(bytes);
+ var config = await _loader.LoadAsync(configPath, cancellationToken);
+ return (config, hash);
+ }
+}
+
+public sealed record RulesPackRegistrationResult
+{
+ public bool Success { get; init; }
+ public bool WasAlreadyPresent { get; init; }
+ public bool WasNotPresent { get; init; }
+ public string? Source { get; init; }
+ public string? ErrorMessage { get; init; }
+
+ public static RulesPackRegistrationResult Added(string source) =>
+ new() { Success = true, Source = source };
+
+ public static RulesPackRegistrationResult Removed(string source) =>
+ new() { Success = true, Source = source };
+
+ public static RulesPackRegistrationResult AlreadyPresent(string source) =>
+ new() { Success = true, WasAlreadyPresent = true, Source = source };
+
+ public static RulesPackRegistrationResult NotPresent(string source) =>
+ new() { Success = true, WasNotPresent = true, Source = source };
+
+ public static RulesPackRegistrationResult Fail(string error) =>
+ new() { Success = false, ErrorMessage = error };
+}
diff --git a/src/Steergen.Core/Configuration/SteergenConfigLoader.cs b/src/Steergen.Core/Configuration/SteergenConfigLoader.cs
index 56e9329..8bced5e 100644
--- a/src/Steergen.Core/Configuration/SteergenConfigLoader.cs
+++ b/src/Steergen.Core/Configuration/SteergenConfigLoader.cs
@@ -1,4 +1,6 @@
using Steergen.Core.Model;
+using Steergen.Core.Packs;
+using Steergen.Core.Validation;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
@@ -18,11 +20,31 @@ public async Task LoadAsync(string filePath, Cancellation
return MapToModel(config);
}
+ ///
+ /// Checks the raw YAML for the deprecated globalRoot field.
+ /// Returns a CFG001 diagnostic error if the field is present.
+ ///
+ public async Task CheckForDeprecatedFieldsAsync(string filePath, CancellationToken cancellationToken = default)
+ {
+ var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
+ var config = Deserializer.Deserialize(content);
+
+ if (config.GlobalRoot is not null)
+ {
+ return new Diagnostic(
+ "CFG001",
+ "The 'globalRoot' configuration field has been removed. Use rules packs with 'scope: global' instead. " +
+ "See migration guide: https://github.com/aabs/steergen/docs/migration/globalroot-removal.md",
+ DiagnosticSeverity.Error);
+ }
+
+ return null;
+ }
+
private static SteeringConfiguration MapToModel(SteeringConfigurationYaml yaml)
{
return new SteeringConfiguration
{
- GlobalRoot = yaml.GlobalRoot,
ProjectRoot = yaml.ProjectRoot,
GenerationRoot = yaml.GenerationRoot,
ActiveProfiles = yaml.ActiveProfiles ?? [],
@@ -38,6 +60,22 @@ private static SteeringConfiguration MapToModel(SteeringConfigurationYaml yaml)
}).ToList(),
RegisteredTargets = yaml.RegisteredTargets ?? [],
TemplatePackVersion = yaml.TemplatePackVersion,
+ TemplatePack = yaml.TemplatePack is not null
+ ? new TemplatePackConfig
+ {
+ Source = yaml.TemplatePack.Source,
+ Ref = yaml.TemplatePack.Ref,
+ LocalPath = yaml.TemplatePack.LocalPath,
+ }
+ : null,
+ RulesPacks = (yaml.RulesPacks ?? [])
+ .Select(r => new RulesPackEntry
+ {
+ Source = r.Source ?? string.Empty,
+ Ref = r.Ref,
+ Path = r.Path,
+ Scope = r.Scope,
+ }).ToList(),
};
}
@@ -50,6 +88,8 @@ internal sealed class SteeringConfigurationYaml
public List? Targets { get; set; }
public List? RegisteredTargets { get; set; }
public string? TemplatePackVersion { get; set; }
+ public TemplatePackConfigYaml? TemplatePack { get; set; }
+ public List? RulesPacks { get; set; }
}
internal sealed class TargetConfigurationYaml
@@ -61,4 +101,19 @@ internal sealed class TargetConfigurationYaml
public Dictionary? FormatOptions { get; set; }
public List? RequiredMetadata { get; set; }
}
+
+ internal sealed class TemplatePackConfigYaml
+ {
+ public string? Source { get; set; }
+ public string? Ref { get; set; }
+ public string? LocalPath { get; set; }
+ }
+
+ internal sealed class RulesPackEntryYaml
+ {
+ public string? Source { get; set; }
+ public string? Ref { get; set; }
+ public string? Path { get; set; }
+ public PackScope? Scope { get; set; }
+ }
}
diff --git a/src/Steergen.Core/Configuration/SteergenConfigWriter.cs b/src/Steergen.Core/Configuration/SteergenConfigWriter.cs
index 7917527..dc3806c 100644
--- a/src/Steergen.Core/Configuration/SteergenConfigWriter.cs
+++ b/src/Steergen.Core/Configuration/SteergenConfigWriter.cs
@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using System.Text;
using Steergen.Core.Model;
+using Steergen.Core.Packs;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
@@ -56,7 +57,6 @@ private static SteeringConfigurationYamlOut MapToYaml(SteeringConfiguration conf
{
return new SteeringConfigurationYamlOut
{
- GlobalRoot = config.GlobalRoot,
ProjectRoot = config.ProjectRoot,
GenerationRoot = config.GenerationRoot,
ActiveProfiles = config.ActiveProfiles,
@@ -71,18 +71,36 @@ private static SteeringConfigurationYamlOut MapToYaml(SteeringConfiguration conf
}).ToList(),
RegisteredTargets = config.RegisteredTargets,
TemplatePackVersion = config.TemplatePackVersion,
+ TemplatePack = config.TemplatePack is not null
+ ? new TemplatePackConfigYamlOut
+ {
+ Source = config.TemplatePack.Source,
+ Ref = config.TemplatePack.Ref,
+ LocalPath = config.TemplatePack.LocalPath,
+ }
+ : null,
+ RulesPacks = config.RulesPacks.Count > 0
+ ? config.RulesPacks.Select(r => new RulesPackEntryYamlOut
+ {
+ Source = r.Source,
+ Ref = r.Ref,
+ Path = r.Path,
+ Scope = r.Scope,
+ }).ToList()
+ : null,
};
}
private sealed class SteeringConfigurationYamlOut
{
- public string? GlobalRoot { get; set; }
public string? ProjectRoot { get; set; }
public string? GenerationRoot { get; set; }
public IReadOnlyList? ActiveProfiles { get; set; }
public List? Targets { get; set; }
public IReadOnlyList? RegisteredTargets { get; set; }
public string? TemplatePackVersion { get; set; }
+ public TemplatePackConfigYamlOut? TemplatePack { get; set; }
+ public List? RulesPacks { get; set; }
}
private sealed class TargetConfigurationYamlOut
@@ -94,4 +112,19 @@ private sealed class TargetConfigurationYamlOut
public Dictionary? FormatOptions { get; set; }
public List? RequiredMetadata { get; set; }
}
+
+ private sealed class TemplatePackConfigYamlOut
+ {
+ public string? Source { get; set; }
+ public string? Ref { get; set; }
+ public string? LocalPath { get; set; }
+ }
+
+ private sealed class RulesPackEntryYamlOut
+ {
+ public string? Source { get; set; }
+ public string? Ref { get; set; }
+ public string? Path { get; set; }
+ public PackScope? Scope { get; set; }
+ }
}
diff --git a/src/Steergen.Core/Configuration/TargetRegistrationService.cs b/src/Steergen.Core/Configuration/TargetRegistrationService.cs
index 3e97adb..67e3d00 100644
--- a/src/Steergen.Core/Configuration/TargetRegistrationService.cs
+++ b/src/Steergen.Core/Configuration/TargetRegistrationService.cs
@@ -1,4 +1,5 @@
using Steergen.Core.Model;
+using Steergen.Core.Targets;
namespace Steergen.Core.Configuration;
@@ -19,6 +20,11 @@ public async Task AddAsync(
if (!File.Exists(configPath))
return TargetRegistrationResult.Fail($"Config file not found: {configPath}");
+ // Verify target is available (built-in or from configured pack's providedTargets)
+ if (!TargetRegistry.IsAvailable(targetId))
+ return TargetRegistrationResult.Fail(
+ $"Target '{targetId}' is not available. It must be a built-in target or provided by a configured template pack's 'providedTargets'.");
+
var (config, hash) = await ReadWithHash(configPath, cancellationToken);
if (config.RegisteredTargets.Contains(targetId, StringComparer.Ordinal))
diff --git a/src/Steergen.Core/Configuration/TemplatePackService.cs b/src/Steergen.Core/Configuration/TemplatePackService.cs
new file mode 100644
index 0000000..42eb4e1
--- /dev/null
+++ b/src/Steergen.Core/Configuration/TemplatePackService.cs
@@ -0,0 +1,117 @@
+using Steergen.Core.Model;
+using Steergen.Core.Packs;
+using Steergen.Core.Targets;
+
+namespace Steergen.Core.Configuration;
+
+///
+/// Manages template pack configuration in the steergen config file.
+/// Uses optimistic locking to detect concurrent modifications.
+///
+public sealed class TemplatePackService
+{
+ private readonly SteergenConfigLoader _loader = new();
+ private readonly SteergenConfigWriter _writer = new();
+ private readonly PackManifestParser _manifestParser = new();
+
+ ///
+ /// Removes the template pack configuration from the config file.
+ /// Sets to null.
+ /// Emits TP010 error if the pack provides targets that are still registered.
+ ///
+ public async Task RemoveAsync(
+ string configPath,
+ CancellationToken cancellationToken = default)
+ {
+ if (!File.Exists(configPath))
+ return TemplatePackResult.Fail($"Config file not found: {configPath}");
+
+ var (config, hash) = await ReadWithHash(configPath, cancellationToken);
+
+ if (config.TemplatePack is null)
+ return TemplatePackResult.NotConfigured();
+
+ // Check if removing the pack would orphan registered targets (TP010)
+ var packName = ResolvePackName(config.TemplatePack);
+ if (packName is not null)
+ {
+ var diagnostics = TargetRegistry.ValidatePackRemoval(packName, config.RegisteredTargets.ToList());
+ if (diagnostics.Count > 0)
+ {
+ // Return the first TP010 error message
+ return TemplatePackResult.Fail(diagnostics[0].Message);
+ }
+ }
+
+ var updated = config with { TemplatePack = null };
+
+ await _writer.WriteAsync(configPath, updated, hash, cancellationToken);
+ return TemplatePackResult.Removed();
+ }
+
+ ///
+ /// Resolves the pack name from the configured template pack by parsing its manifest.
+ /// Checks local path first, then falls back to the cached GitHub pack path.
+ /// Returns null if the manifest cannot be found or parsed.
+ ///
+ private string? ResolvePackName(TemplatePackConfig templatePack)
+ {
+ // Try local path first
+ if (!string.IsNullOrWhiteSpace(templatePack.LocalPath))
+ {
+ var manifest = _manifestParser.Parse(templatePack.LocalPath);
+ return manifest?.Name;
+ }
+
+ // Try GitHub source via cache
+ if (!string.IsNullOrWhiteSpace(templatePack.Source))
+ {
+ var source = GitHubPackSourceParser.Parse(templatePack.Source, templatePack.Ref);
+ if (source is not null)
+ {
+ var cacheBase = System.IO.Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".steergen");
+ var downloader = new PackDownloader(new HttpClient(), cacheBase);
+ var cachedPath = downloader.GetCachedPath(source, PackType.Template);
+
+ if (Directory.Exists(cachedPath))
+ {
+ var manifest = _manifestParser.Parse(cachedPath);
+ return manifest?.Name;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private async Task<(SteeringConfiguration Config, string Hash)> ReadWithHash(
+ string configPath,
+ CancellationToken cancellationToken)
+ {
+ var bytes = await File.ReadAllBytesAsync(configPath, cancellationToken);
+ var hash = SteergenConfigWriter.ComputeFileHash(bytes);
+ var config = await _loader.LoadAsync(configPath, cancellationToken);
+ return (config, hash);
+ }
+}
+
+///
+/// Result of a template pack management operation.
+///
+public sealed record TemplatePackResult
+{
+ public bool Success { get; init; }
+ public bool WasNotConfigured { get; init; }
+ public string? ErrorMessage { get; init; }
+
+ public static TemplatePackResult Removed() =>
+ new() { Success = true };
+
+ public static TemplatePackResult NotConfigured() =>
+ new() { Success = true, WasNotConfigured = true };
+
+ public static TemplatePackResult Fail(string error) =>
+ new() { Success = false, ErrorMessage = error };
+}
diff --git a/src/Steergen.Core/Generation/GenerationPipeline.cs b/src/Steergen.Core/Generation/GenerationPipeline.cs
index c722c43..762586f 100644
--- a/src/Steergen.Core/Generation/GenerationPipeline.cs
+++ b/src/Steergen.Core/Generation/GenerationPipeline.cs
@@ -1,6 +1,7 @@
using Steergen.Core.Configuration;
using Steergen.Core.Model;
using Steergen.Core.Merge;
+using Steergen.Core.Packs;
using Steergen.Core.Validation;
namespace Steergen.Core.Generation;
@@ -54,6 +55,7 @@ public sealed class GenerationPipeline
///
/// Optional resolved global root path for context variable substitution in layout paths.
/// Optional resolved project root path for context variable substitution in layout paths.
+ /// Optional rules pack documents grouped by scope for merge precedence.
public async Task RunAsync(
IEnumerable globalDocuments,
IEnumerable projectDocuments,
@@ -63,7 +65,8 @@ public async Task RunAsync(
CancellationToken cancellationToken = default,
string? manifestOutputPath = null,
string? globalRoot = null,
- string? projectRoot = null)
+ string? projectRoot = null,
+ IReadOnlyList? packDocuments = null)
{
var allDiagnostics = new List();
var globalList = globalDocuments.ToList();
@@ -83,7 +86,9 @@ public async Task RunAsync(
return new GenerationResult(false, allDiagnostics, 0, failureManifest);
}
- var model = _resolver.Resolve(globalList, projectList, activeProfiles);
+ var model = packDocuments is { Count: > 0 }
+ ? _resolver.Resolve(projectList, packDocuments, activeProfiles)
+ : _resolver.Resolve(globalList, projectList, activeProfiles);
var configMap = targetConfigs
.Where(t => t.Id is not null)
@@ -99,10 +104,25 @@ public async Task RunAsync(
try
{
- var layout = await _layoutLoader.LoadAsync(
- target.TargetId,
- config.LayoutOverridePath,
- cancellationToken);
+ TargetLayoutDefinition layout;
+
+ // Pack-provided targets use their own layout file from the pack directory
+ // rather than an embedded resource.
+ if (target is Targets.PackTargetComponent packTarget)
+ {
+ layout = await _layoutLoader.LoadFromFileAsync(
+ target.TargetId,
+ packTarget.DefaultLayoutPath,
+ config.LayoutOverridePath,
+ cancellationToken);
+ }
+ else
+ {
+ layout = await _layoutLoader.LoadAsync(
+ target.TargetId,
+ config.LayoutOverridePath,
+ cancellationToken);
+ }
var provenanceSource = config.LayoutOverridePath is not null
? RouteProvenance.Merged
diff --git a/src/Steergen.Core/Merge/SteeringResolver.cs b/src/Steergen.Core/Merge/SteeringResolver.cs
index 29dd37d..0e8c51b 100644
--- a/src/Steergen.Core/Merge/SteeringResolver.cs
+++ b/src/Steergen.Core/Merge/SteeringResolver.cs
@@ -1,9 +1,167 @@
using Steergen.Core.Model;
+using Steergen.Core.Packs;
+using Steergen.Core.Validation;
namespace Steergen.Core.Merge;
public sealed class SteeringResolver
{
+ ///
+ /// Extended resolve method that accepts rules pack documents with scope metadata.
+ /// Merge precedence: project-local > project-scoped packs > supplemental > global.
+ /// Within the same scope level, declaration order (earlier in list) wins.
+ /// Duplicate rule IDs at the same scope emit a warning diagnostic (RP004).
+ ///
+ public ResolvedSteeringModel Resolve(
+ IEnumerable projectDocuments,
+ IReadOnlyList packDocuments,
+ IEnumerable activeProfiles)
+ {
+ var profiles = activeProfiles.ToList();
+ var diagnostics = new List();
+
+ // Collect all documents in merge order for the SourceIndex and Documents output.
+ var allDocuments = new List();
+
+ // ── Phase 1: Build the rule map with scope-based precedence ──────────────
+ // We process from lowest to highest precedence so that higher-precedence
+ // rules overwrite lower-precedence ones in the map.
+ // Precedence (lowest to highest): Global(1) < Supplemental(2) < Project-scoped packs(3) < Project-local(4)
+
+ // Track which rule IDs have been seen at each scope level for duplicate detection.
+ // Key: (ruleId, scopeLevel), Value: source description (pack name or "project-local")
+ var seenAtScope = new Dictionary<(string RuleId, int ScopeLevel), string>();
+
+ var ruleMap = new Dictionary(StringComparer.Ordinal);
+
+ // Process pack documents grouped by scope, from lowest to highest precedence.
+ // Within each scope group, documents maintain their declaration order (earlier wins).
+ var scopeOrder = new[] { PackScope.Global, PackScope.Supplemental, PackScope.Project };
+
+ foreach (var scope in scopeOrder)
+ {
+ var scopeLevel = ScopePrecedence(scope);
+
+ // Find all ScopedPackDocuments entries matching this scope, preserving declaration order.
+ foreach (var scopedPack in packDocuments.Where(p => p.Scope == scope))
+ {
+ foreach (var doc in scopedPack.Documents)
+ {
+ allDocuments.Add(doc);
+
+ foreach (var rule in doc.Rules)
+ {
+ if (rule.Id is null)
+ continue;
+
+ var scopeKey = (rule.Id, scopeLevel);
+
+ if (seenAtScope.TryGetValue(scopeKey, out var existingSource))
+ {
+ // Duplicate rule ID at the same scope level — emit warning.
+ // The earlier declaration already won, so we skip this one.
+ var currentSource = rule.SourcePackName ?? "unknown";
+ diagnostics.Add(new Diagnostic(
+ "RP004",
+ $"Duplicate rule ID '{rule.Id}' at scope '{scope}': " +
+ $"already declared by '{existingSource}', ignoring from '{currentSource}'.",
+ DiagnosticSeverity.Warning));
+ continue;
+ }
+
+ seenAtScope[scopeKey] = rule.SourcePackName ?? "unknown";
+
+ // Apply precedence: only overwrite if this scope is higher or equal
+ // (but within same scope, first wins — so we only write if not already present at same level)
+ if (!ruleMap.TryGetValue(rule.Id, out var existing) || scopeLevel > existing.Precedence)
+ {
+ var stem = doc.SourcePath is not null
+ ? Path.GetFileNameWithoutExtension(doc.SourcePath)
+ : doc.Id;
+
+ ruleMap[rule.Id] = (rule with
+ {
+ InputFileStem = stem,
+ SourceScope = MapPackScopeToRouteScope(scope)
+ }, scopeLevel);
+ }
+ }
+ }
+ }
+ }
+
+ // Process project-local documents (highest precedence = 4)
+ var projectList = projectDocuments.ToList();
+ foreach (var doc in projectList)
+ {
+ allDocuments.Add(doc);
+
+ var stem = doc.SourcePath is not null
+ ? Path.GetFileNameWithoutExtension(doc.SourcePath)
+ : doc.Id;
+
+ foreach (var rule in doc.Rules)
+ {
+ if (rule.Id is null)
+ continue;
+
+ // Project-local always wins (precedence 4), overwrite unconditionally
+ ruleMap[rule.Id] = (rule with
+ {
+ InputFileStem = stem,
+ SourceScope = RouteScope.Project
+ }, 4);
+ }
+ }
+
+ // ── Phase 2: Build output ────────────────────────────────────────────────
+
+ var sortedRules = ruleMap.Values
+ .Select(v => v.Rule)
+ .OrderBy(r => r.Id, StringComparer.Ordinal)
+ .ToList();
+
+ var sortedDocs = allDocuments
+ .Where(d => d.Id is not null)
+ .DistinctBy(d => d.Id, StringComparer.Ordinal)
+ .OrderBy(d => d.Id, StringComparer.Ordinal)
+ .ToList();
+
+ var sourceIndex = sortedDocs
+ .ToDictionary(d => d.Id!, StringComparer.Ordinal);
+
+ return new ResolvedSteeringModel
+ {
+ Documents = sortedDocs,
+ Rules = sortedRules,
+ ActiveProfiles = profiles,
+ SourceIndex = sourceIndex,
+ Diagnostics = diagnostics,
+ };
+ }
+
+ ///
+ /// Returns the numeric precedence for a PackScope (higher = wins).
+ ///
+ private static int ScopePrecedence(PackScope scope) => scope switch
+ {
+ PackScope.Global => 1,
+ PackScope.Supplemental => 2,
+ PackScope.Project => 3,
+ _ => 0
+ };
+
+ ///
+ /// Maps a PackScope to the corresponding RouteScope for rule tagging.
+ ///
+ private static RouteScope MapPackScopeToRouteScope(PackScope scope) => scope switch
+ {
+ PackScope.Global => RouteScope.Global,
+ PackScope.Project => RouteScope.Project,
+ PackScope.Supplemental => RouteScope.Both, // Supplemental maps to Both (between global and project)
+ _ => RouteScope.Both
+ };
+
public ResolvedSteeringModel Resolve(
IEnumerable globalDocuments,
IEnumerable projectDocuments,
diff --git a/src/Steergen.Core/Model/ResolvedSteeringModel.cs b/src/Steergen.Core/Model/ResolvedSteeringModel.cs
index 3666118..eaf019f 100644
--- a/src/Steergen.Core/Model/ResolvedSteeringModel.cs
+++ b/src/Steergen.Core/Model/ResolvedSteeringModel.cs
@@ -1,3 +1,5 @@
+using Steergen.Core.Validation;
+
namespace Steergen.Core.Model;
public record ResolvedSteeringModel
@@ -6,4 +8,5 @@ public record ResolvedSteeringModel
public IReadOnlyList Rules { get; init; } = [];
public IReadOnlyList ActiveProfiles { get; init; } = [];
public IReadOnlyDictionary SourceIndex { get; init; } = new Dictionary();
+ public IReadOnlyList Diagnostics { get; init; } = [];
}
diff --git a/src/Steergen.Core/Model/SteeringConfiguration.cs b/src/Steergen.Core/Model/SteeringConfiguration.cs
index 9bee2f8..d3be62e 100644
--- a/src/Steergen.Core/Model/SteeringConfiguration.cs
+++ b/src/Steergen.Core/Model/SteeringConfiguration.cs
@@ -1,14 +1,65 @@
+using Steergen.Core.Packs;
+
namespace Steergen.Core.Model;
public record SteeringConfiguration
{
- public string? GlobalRoot { get; init; }
public string? ProjectRoot { get; init; }
public string? GenerationRoot { get; init; }
public IReadOnlyList ActiveProfiles { get; init; } = [];
public IReadOnlyList Targets { get; init; } = [];
public IReadOnlyList RegisteredTargets { get; init; } = [];
public string? TemplatePackVersion { get; init; }
+ public TemplatePackConfig? TemplatePack { get; init; }
+ public IReadOnlyList RulesPacks { get; init; } = [];
+}
+
+///
+/// Configuration for a template pack source. Either (GitHub)
+/// or should be specified, not both.
+///
+public sealed record TemplatePackConfig
+{
+ ///
+ /// GitHub source in the format "github:{owner}/{repo}".
+ ///
+ public string? Source { get; init; }
+
+ ///
+ /// Git tag, branch, or 40-character commit SHA.
+ ///
+ public string? Ref { get; init; }
+
+ ///
+ /// Alternative: local filesystem path to a template pack directory.
+ ///
+ public string? LocalPath { get; init; }
+}
+
+///
+/// Configuration entry for a rules pack in the rulesPacks list.
+///
+public sealed record RulesPackEntry
+{
+ ///
+ /// GitHub source in the format "github:{owner}/{repo}".
+ ///
+ public required string Source { get; init; }
+
+ ///
+ /// Git tag, branch, or 40-character commit SHA.
+ ///
+ public string? Ref { get; init; }
+
+ ///
+ /// Subdirectory within the repository when multiple rule sets are published in one repo.
+ ///
+ public string? Path { get; init; }
+
+ ///
+ /// Consumer scope override. When set, overrides the scope declared in the pack manifest.
+ ///
+ public PackScope? Scope { get; init; }
}
public record TargetConfiguration
diff --git a/src/Steergen.Core/Model/SteeringRule.cs b/src/Steergen.Core/Model/SteeringRule.cs
index 774952c..5a71dd5 100644
--- a/src/Steergen.Core/Model/SteeringRule.cs
+++ b/src/Steergen.Core/Model/SteeringRule.cs
@@ -1,3 +1,5 @@
+using Steergen.Core.Packs;
+
namespace Steergen.Core.Model;
public record SteeringRule
@@ -16,4 +18,14 @@ public record SteeringRule
/// Set during model resolution; used for ${inputFileStem} route substitution.
///
public string? InputFileStem { get; init; }
+ ///
+ /// The name of the rules pack from which this rule was loaded.
+ /// Null for project-local rules.
+ ///
+ public string? SourcePackName { get; init; }
+ ///
+ /// The effective scope of the rules pack from which this rule was loaded.
+ /// Null for project-local rules.
+ ///
+ public PackScope? SourcePackScope { get; init; }
}
diff --git a/src/Steergen.Core/Packs/GitHubPackSource.cs b/src/Steergen.Core/Packs/GitHubPackSource.cs
new file mode 100644
index 0000000..29beaab
--- /dev/null
+++ b/src/Steergen.Core/Packs/GitHubPackSource.cs
@@ -0,0 +1,22 @@
+namespace Steergen.Core.Packs;
+
+///
+/// Identifies a pack published in a GitHub repository.
+///
+public sealed record GitHubPackSource
+{
+ public required string Owner { get; init; }
+ public required string Repo { get; init; }
+
+ ///
+ /// Git tag, branch, or 40-character commit SHA.
+ /// When null, the repository default branch is used.
+ ///
+ public string? Ref { get; init; }
+
+ ///
+ /// Subdirectory within the repository containing the pack.
+ /// Used when multiple packs are published in a single repo.
+ ///
+ public string? Path { get; init; }
+}
diff --git a/src/Steergen.Core/Packs/GitHubPackSourceParser.cs b/src/Steergen.Core/Packs/GitHubPackSourceParser.cs
new file mode 100644
index 0000000..75d5643
--- /dev/null
+++ b/src/Steergen.Core/Packs/GitHubPackSourceParser.cs
@@ -0,0 +1,53 @@
+namespace Steergen.Core.Packs;
+
+///
+/// Parses and formats the github:{owner}/{repo} pack source notation
+/// used in steergen.config.yaml.
+///
+public static class GitHubPackSourceParser
+{
+ private const string Prefix = "github:";
+
+ ///
+ /// Parses "github:{owner}/{repo}" into a .
+ /// Returns null if the format is invalid (missing prefix, missing slash,
+ /// empty owner, or empty repo).
+ ///
+ public static GitHubPackSource? Parse(string source, string? refValue = null, string? path = null)
+ {
+ if (string.IsNullOrWhiteSpace(source))
+ return null;
+
+ if (!source.StartsWith(Prefix, StringComparison.Ordinal))
+ return null;
+
+ var ownerRepo = source[Prefix.Length..];
+
+ var slashIndex = ownerRepo.IndexOf('/');
+ if (slashIndex < 0)
+ return null;
+
+ var owner = ownerRepo[..slashIndex];
+ var repo = ownerRepo[(slashIndex + 1)..];
+
+ if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo))
+ return null;
+
+ return new GitHubPackSource
+ {
+ Owner = owner,
+ Repo = repo,
+ Ref = refValue,
+ Path = path
+ };
+ }
+
+ ///
+ /// Formats a back to the canonical
+ /// github:{owner}/{repo} string representation.
+ ///
+ public static string Format(GitHubPackSource source)
+ {
+ return $"{Prefix}{source.Owner}/{source.Repo}";
+ }
+}
diff --git a/src/Steergen.Core/Packs/PackDownloadResult.cs b/src/Steergen.Core/Packs/PackDownloadResult.cs
new file mode 100644
index 0000000..6bdd33d
--- /dev/null
+++ b/src/Steergen.Core/Packs/PackDownloadResult.cs
@@ -0,0 +1,18 @@
+using Steergen.Core.Validation;
+
+namespace Steergen.Core.Packs;
+
+///
+/// Result of a pack download operation from GitHub.
+///
+public sealed record PackDownloadResult
+{
+ public bool Success { get; init; }
+
+ ///
+ /// Local filesystem path where the pack was cached on success.
+ ///
+ public string? CachePath { get; init; }
+
+ public IReadOnlyList Diagnostics { get; init; } = [];
+}
diff --git a/src/Steergen.Core/Packs/PackDownloader.cs b/src/Steergen.Core/Packs/PackDownloader.cs
new file mode 100644
index 0000000..9e12646
--- /dev/null
+++ b/src/Steergen.Core/Packs/PackDownloader.cs
@@ -0,0 +1,365 @@
+using System.Formats.Tar;
+using System.IO.Compression;
+using Steergen.Core.Validation;
+
+namespace Steergen.Core.Packs;
+
+///
+/// Handles GitHub archive download and extraction to local cache.
+///
+public sealed class PackDownloader
+{
+ private readonly HttpClient _httpClient;
+ private readonly string _cacheBaseDirectory;
+
+ public PackDownloader(HttpClient httpClient, string cacheBaseDirectory)
+ {
+ _httpClient = httpClient;
+ _cacheBaseDirectory = cacheBaseDirectory;
+ }
+
+ ///
+ /// Downloads a pack from GitHub to the local cache.
+ /// Returns the local cache path on success.
+ ///
+ public async Task DownloadAsync(
+ GitHubPackSource source,
+ PackType packType,
+ bool force,
+ CancellationToken cancellationToken = default)
+ {
+ var cachePath = GetCachedPath(source, packType);
+
+ // If immutable pin, cache exists, and not forced — skip download
+ if (!force && IsImmutablePin(source.Ref) && Directory.Exists(cachePath))
+ {
+ return new PackDownloadResult
+ {
+ Success = true,
+ CachePath = cachePath
+ };
+ }
+
+ var refValue = source.Ref ?? "HEAD";
+ var archiveUrl = $"https://github.com/{source.Owner}/{source.Repo}/archive/{refValue}.tar.gz";
+
+ // Download the archive
+ HttpResponseMessage response;
+ try
+ {
+ response = await _httpClient.GetAsync(archiveUrl, cancellationToken);
+ }
+ catch (HttpRequestException ex)
+ {
+ return new PackDownloadResult
+ {
+ Success = false,
+ Diagnostics = [new Diagnostic(
+ "DL001",
+ $"Failed to download pack from {archiveUrl}: {ex.Message}",
+ DiagnosticSeverity.Error)]
+ };
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ return new PackDownloadResult
+ {
+ Success = false,
+ Diagnostics = [new Diagnostic(
+ "DL001",
+ $"GitHub repository not accessible: HTTP {(int)response.StatusCode} from {archiveUrl}",
+ DiagnosticSeverity.Error)]
+ };
+ }
+
+ // Extract to a temp directory for atomic replacement
+ var tempDir = Path.Combine(Path.GetTempPath(), $"steergen-pack-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ await using var gzipStream = new GZipStream(responseStream, CompressionMode.Decompress);
+ using var tarReader = new TarReader(gzipStream);
+
+ // GitHub tarballs have a top-level directory like {repo}-{ref}/
+ // We need to detect and strip this prefix
+ string? topLevelPrefix = null;
+ var diagnostics = new List();
+
+ while (await tarReader.GetNextEntryAsync(cancellationToken: cancellationToken) is { } entry)
+ {
+ var entryName = entry.Name;
+
+ // Validate path safety
+ if (!IsPathSafe(entryName))
+ {
+ // Clean up temp directory
+ DeleteDirectorySafe(tempDir);
+ return new PackDownloadResult
+ {
+ Success = false,
+ Diagnostics = [new Diagnostic(
+ "DL003",
+ $"Archive contains path traversal or unsafe path: {entryName}",
+ DiagnosticSeverity.Error)]
+ };
+ }
+
+ // Detect the top-level GitHub directory prefix (e.g. repo-ref/).
+ // Some archives may start with metadata entries that have no slash;
+ // do not lock in an empty prefix until we see a filesystem entry.
+ if (topLevelPrefix is null)
+ {
+ var firstSlash = entryName.IndexOf('/');
+ if (firstSlash > 0)
+ {
+ topLevelPrefix = entryName[..(firstSlash + 1)];
+ }
+ }
+
+ // Strip the top-level prefix
+ var relativePath = topLevelPrefix is not null
+ && topLevelPrefix.Length > 0
+ && entryName.StartsWith(topLevelPrefix, StringComparison.Ordinal)
+ ? entryName[topLevelPrefix.Length..]
+ : entryName;
+
+ // Skip the top-level directory entry itself
+ if (string.IsNullOrEmpty(relativePath))
+ continue;
+
+ // When source.Path is specified, only extract files under that subdirectory
+ if (source.Path is not null)
+ {
+ var subDirPrefix = source.Path.TrimEnd('/') + "/";
+ if (!relativePath.StartsWith(subDirPrefix, StringComparison.Ordinal)
+ && relativePath != source.Path.TrimEnd('/'))
+ {
+ continue; // Skip entries not under the specified subdirectory
+ }
+
+ // Strip the subdirectory prefix from the relative path
+ relativePath = relativePath.StartsWith(subDirPrefix, StringComparison.Ordinal)
+ ? relativePath[subDirPrefix.Length..]
+ : string.Empty;
+
+ if (string.IsNullOrEmpty(relativePath))
+ continue;
+ }
+
+ // Validate the stripped path is still safe
+ if (!IsPathSafe(relativePath))
+ {
+ DeleteDirectorySafe(tempDir);
+ return new PackDownloadResult
+ {
+ Success = false,
+ Diagnostics = [new Diagnostic(
+ "DL004",
+ $"Archive contains file outside expected directory structure: {relativePath}",
+ DiagnosticSeverity.Error)]
+ };
+ }
+
+ var destPath = Path.Combine(tempDir, relativePath.Replace('/', Path.DirectorySeparatorChar));
+
+ // Verify the resolved path is within the temp directory
+ var fullDestPath = Path.GetFullPath(destPath);
+ var fullTempDir = Path.GetFullPath(tempDir) + Path.DirectorySeparatorChar;
+ if (!fullDestPath.StartsWith(fullTempDir, StringComparison.OrdinalIgnoreCase))
+ {
+ DeleteDirectorySafe(tempDir);
+ return new PackDownloadResult
+ {
+ Success = false,
+ Diagnostics = [new Diagnostic(
+ "DL004",
+ $"Archive entry resolves outside expected directory: {relativePath}",
+ DiagnosticSeverity.Error)]
+ };
+ }
+
+ switch (entry.EntryType)
+ {
+ case TarEntryType.Directory:
+ Directory.CreateDirectory(destPath);
+ break;
+
+ case TarEntryType.RegularFile:
+ case TarEntryType.V7RegularFile:
+ var parentDir = Path.GetDirectoryName(destPath);
+ if (parentDir is not null)
+ Directory.CreateDirectory(parentDir);
+
+ await using (var fileStream = File.Create(destPath))
+ {
+ if (entry.DataStream is not null)
+ {
+ await entry.DataStream.CopyToAsync(fileStream, cancellationToken);
+ }
+ }
+ break;
+
+ // Skip symlinks and other entry types for security
+ default:
+ break;
+ }
+ }
+
+ // Validate pack.yaml presence
+ var packYamlPath = Path.Combine(tempDir, "pack.yaml");
+ if (!File.Exists(packYamlPath))
+ {
+ DeleteDirectorySafe(tempDir);
+ return new PackDownloadResult
+ {
+ Success = false,
+ Diagnostics = [new Diagnostic(
+ "DL002",
+ $"Downloaded archive does not contain pack.yaml",
+ DiagnosticSeverity.Error)]
+ };
+ }
+
+ // Atomic swap: move temp directory into cache location
+ var cacheParent = Path.GetDirectoryName(cachePath.TrimEnd(Path.DirectorySeparatorChar));
+ if (cacheParent is not null)
+ Directory.CreateDirectory(cacheParent);
+
+ // Remove existing cache directory if present (atomic replacement)
+ if (Directory.Exists(cachePath))
+ Directory.Delete(cachePath, recursive: true);
+
+ Directory.Move(tempDir, cachePath.TrimEnd(Path.DirectorySeparatorChar));
+
+ return new PackDownloadResult
+ {
+ Success = true,
+ CachePath = cachePath,
+ Diagnostics = diagnostics
+ };
+ }
+ catch (Exception) when (!cancellationToken.IsCancellationRequested)
+ {
+ // Preserve existing cache on download/extraction failure
+ DeleteDirectorySafe(tempDir);
+ throw;
+ }
+ }
+
+ private static void DeleteDirectorySafe(string path)
+ {
+ try
+ {
+ if (Directory.Exists(path))
+ Directory.Delete(path, recursive: true);
+ }
+ catch
+ {
+ // Best-effort cleanup; don't mask the original error
+ }
+ }
+
+ ///
+ /// Returns the local cache path for a given source and pack type.
+ /// Path format: {cacheBaseDirectory}/{packTypeDir}/{owner}/{repo}/{ref}/
+ /// where packTypeDir is "packs" for Template and "rules" for Rules.
+ ///
+ public string GetCachedPath(GitHubPackSource source, PackType packType)
+ {
+ var packTypeDir = packType switch
+ {
+ PackType.Template => "packs",
+ PackType.Rules => "rules",
+ _ => throw new ArgumentOutOfRangeException(nameof(packType))
+ };
+
+ var refValue = source.Ref ?? "HEAD";
+
+ return Path.Combine(
+ _cacheBaseDirectory,
+ packTypeDir,
+ source.Owner,
+ source.Repo,
+ refValue) + Path.DirectorySeparatorChar;
+ }
+
+ ///
+ /// Determines if a ref value is an immutable pin — a full 40-character
+ /// lowercase hexadecimal Git commit SHA.
+ ///
+ /// The Git ref string to check (tag, branch, or SHA).
+ ///
+ /// true if is exactly 40 characters long
+ /// and consists entirely of lowercase hexadecimal characters (0-9, a-f);
+ /// false otherwise (including when is null).
+ ///
+ public static bool IsImmutablePin(string? refValue)
+ {
+ if (refValue is null || refValue.Length != 40)
+ return false;
+
+ foreach (var c in refValue)
+ {
+ if (c is not ((>= '0' and <= '9') or (>= 'a' and <= 'f')))
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Validates that an archive entry path is safe for extraction into a pack directory.
+ /// A path is considered unsafe (returns false) if:
+ /// ]
+ /// - It is null or empty
+ /// - It contains the path traversal sequence ../ or ..\
+ /// - It starts with / or \ (absolute path)
+ /// - The normalized resolved path would escape the pack directory root
+ ///
+ ///
+ /// The relative file path from the archive entry.
+ ///
+ /// true if the path is safe for extraction within a pack directory;
+ /// false if the path contains traversal sequences, is absolute, or would
+ /// resolve outside the pack directory structure.
+ ///
+ public static bool IsPathSafe(string? entryPath)
+ {
+ if (string.IsNullOrEmpty(entryPath))
+ return false;
+
+ // Reject absolute paths (starting with / or \)
+ if (entryPath[0] is '/' or '\\')
+ return false;
+
+ // Normalize separators to forward slash for consistent checking
+ var normalized = entryPath.Replace('\\', '/');
+
+ // Reject paths containing the traversal sequence "../"
+ if (normalized.Contains("../"))
+ return false;
+
+ // Resolve the path segments to detect traversal that escapes the root
+ var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ var depth = 0;
+
+ foreach (var segment in segments)
+ {
+ if (segment == "..")
+ {
+ depth--;
+ if (depth < 0)
+ return false; // Would escape the pack directory
+ }
+ else if (segment != ".")
+ {
+ depth++;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/Steergen.Core/Packs/PackManifest.cs b/src/Steergen.Core/Packs/PackManifest.cs
new file mode 100644
index 0000000..47ba004
--- /dev/null
+++ b/src/Steergen.Core/Packs/PackManifest.cs
@@ -0,0 +1,34 @@
+namespace Steergen.Core.Packs;
+
+///
+/// Shared manifest model for both template packs and rules packs.
+/// Parsed from pack.yaml at the root of a pack directory.
+///
+public sealed record PackManifest
+{
+ public required string Name { get; init; }
+ public required string Version { get; init; }
+ public required string MinSteergenVersion { get; init; }
+
+ ///
+ /// Required for rules packs. Determines merge precedence.
+ ///
+ public PackScope? Scope { get; init; }
+
+ ///
+ /// Target IDs that this template pack overrides (template packs only).
+ /// When null, the pack applies to all targets.
+ ///
+ public IReadOnlyList? Targets { get; init; }
+
+ ///
+ /// Complete target definitions provided by this template pack (external targets).
+ ///
+ public IReadOnlyList? ProvidedTargets { get; init; }
+
+ ///
+ /// Subdirectory containing steering documents (rules packs only).
+ /// Defaults to the pack root directory when null.
+ ///
+ public string? RulesRoot { get; init; }
+}
diff --git a/src/Steergen.Core/Packs/PackManifestParser.cs b/src/Steergen.Core/Packs/PackManifestParser.cs
new file mode 100644
index 0000000..9242479
--- /dev/null
+++ b/src/Steergen.Core/Packs/PackManifestParser.cs
@@ -0,0 +1,237 @@
+using Steergen.Core.Validation;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
+namespace Steergen.Core.Packs;
+
+///
+/// Parses and validates pack.yaml manifest files for template packs and rules packs.
+///
+public sealed class PackManifestParser
+{
+ private const string ManifestFileName = "pack.yaml";
+
+ private static readonly IDeserializer Deserializer = new DeserializerBuilder()
+ .WithNamingConvention(CamelCaseNamingConvention.Instance)
+ .IgnoreUnmatchedProperties()
+ .Build();
+
+ ///
+ /// Parses pack.yaml from the given directory.
+ /// Returns null if pack.yaml does not exist.
+ ///
+ public PackManifest? Parse(string packDirectory)
+ {
+ var manifestPath = Path.Combine(packDirectory, ManifestFileName);
+
+ if (!File.Exists(manifestPath))
+ {
+ return null;
+ }
+
+ var content = File.ReadAllText(manifestPath);
+ var yaml = Deserializer.Deserialize(content);
+
+ return MapToModel(yaml);
+ }
+
+ ///
+ /// Validates manifest fields. Returns diagnostics for missing/invalid fields.
+ /// For rules packs, additionally validates that scope is present and valid.
+ /// Version compatibility is checked against the running Steergen version.
+ ///
+ public IReadOnlyList Validate(
+ PackManifest manifest,
+ PackType packType,
+ string runningSteergenVersion)
+ {
+ var diagnostics = new List();
+
+ // Validate name (non-empty)
+ if (string.IsNullOrWhiteSpace(manifest.Name))
+ {
+ diagnostics.Add(new Diagnostic(
+ "PM001",
+ "Pack manifest 'name' field is required and must be non-empty.",
+ DiagnosticSeverity.Error));
+ }
+
+ // Validate version (valid semver)
+ if (string.IsNullOrWhiteSpace(manifest.Version))
+ {
+ diagnostics.Add(new Diagnostic(
+ "PM002",
+ "Pack manifest 'version' field is required.",
+ DiagnosticSeverity.Error));
+ }
+ else if (!IsValidSemver(manifest.Version))
+ {
+ diagnostics.Add(new Diagnostic(
+ "PM003",
+ $"Pack manifest 'version' field '{manifest.Version}' is not a valid semantic version (expected major.minor.patch).",
+ DiagnosticSeverity.Error));
+ }
+
+ // Validate minSteergenVersion (valid semver)
+ if (string.IsNullOrWhiteSpace(manifest.MinSteergenVersion))
+ {
+ diagnostics.Add(new Diagnostic(
+ "PM004",
+ "Pack manifest 'minSteergenVersion' field is required.",
+ DiagnosticSeverity.Error));
+ }
+ else if (!IsValidSemver(manifest.MinSteergenVersion))
+ {
+ diagnostics.Add(new Diagnostic(
+ "PM005",
+ $"Pack manifest 'minSteergenVersion' field '{manifest.MinSteergenVersion}' is not a valid semantic version (expected major.minor.patch).",
+ DiagnosticSeverity.Error));
+ }
+ else if (IsValidSemver(runningSteergenVersion) &&
+ !IsVersionCompatible(runningSteergenVersion, manifest.MinSteergenVersion))
+ {
+ diagnostics.Add(new Diagnostic(
+ "PM006",
+ $"Running Steergen version '{runningSteergenVersion}' is lower than the required minimum '{manifest.MinSteergenVersion}'.",
+ DiagnosticSeverity.Error));
+ }
+
+ // For rules packs, validate scope is present and valid
+ if (packType == PackType.Rules)
+ {
+ if (manifest.Scope is null)
+ {
+ diagnostics.Add(new Diagnostic(
+ "PM007",
+ "Rules pack manifest 'scope' field is required (must be one of: global, supplemental, project).",
+ DiagnosticSeverity.Error));
+ }
+ }
+
+ return diagnostics;
+ }
+
+ ///
+ /// Validates that a version string matches the semver pattern: major.minor.patch
+ /// where all components are non-negative integers.
+ ///
+ internal static bool IsValidSemver(string version)
+ {
+ if (string.IsNullOrWhiteSpace(version))
+ return false;
+
+ var parts = version.Split('.');
+ if (parts.Length != 3)
+ return false;
+
+ foreach (var part in parts)
+ {
+ if (string.IsNullOrEmpty(part))
+ return false;
+
+ // Reject leading zeros (except for "0" itself)
+ if (part.Length > 1 && part[0] == '0')
+ return false;
+
+ if (!int.TryParse(part, out var value) || value < 0)
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Returns true if runningVersion >= minVersion using standard semver comparison.
+ /// Both versions must be valid semver strings.
+ ///
+ internal static bool IsVersionCompatible(string runningVersion, string minVersion)
+ {
+ var running = ParseSemverParts(runningVersion);
+ var min = ParseSemverParts(minVersion);
+
+ if (running is null || min is null)
+ return false;
+
+ if (running.Value.Major != min.Value.Major)
+ return running.Value.Major > min.Value.Major;
+
+ if (running.Value.Minor != min.Value.Minor)
+ return running.Value.Minor > min.Value.Minor;
+
+ return running.Value.Patch >= min.Value.Patch;
+ }
+
+ private static (int Major, int Minor, int Patch)? ParseSemverParts(string version)
+ {
+ var parts = version.Split('.');
+ if (parts.Length != 3)
+ return null;
+
+ if (!int.TryParse(parts[0], out var major) ||
+ !int.TryParse(parts[1], out var minor) ||
+ !int.TryParse(parts[2], out var patch))
+ return null;
+
+ if (major < 0 || minor < 0 || patch < 0)
+ return null;
+
+ return (major, minor, patch);
+ }
+
+ private static PackManifest MapToModel(PackManifestYaml yaml)
+ {
+ PackScope? scope = ParseScope(yaml.Scope);
+
+ return new PackManifest
+ {
+ Name = yaml.Name ?? string.Empty,
+ Version = yaml.Version ?? string.Empty,
+ MinSteergenVersion = yaml.MinSteergenVersion ?? string.Empty,
+ Scope = scope,
+ Targets = yaml.Targets,
+ ProvidedTargets = yaml.ProvidedTargets?
+ .Select(pt => new ProvidedTargetDefinition
+ {
+ TargetId = pt.TargetId ?? string.Empty,
+ DefaultLayout = pt.DefaultLayout ?? string.Empty,
+ Description = pt.Description,
+ })
+ .ToList(),
+ RulesRoot = yaml.RulesRoot,
+ };
+ }
+
+ private static PackScope? ParseScope(string? scope)
+ {
+ if (string.IsNullOrWhiteSpace(scope))
+ return null;
+
+ return scope.ToLowerInvariant() switch
+ {
+ "global" => PackScope.Global,
+ "supplemental" => PackScope.Supplemental,
+ "project" => PackScope.Project,
+ _ => null,
+ };
+ }
+
+ // ── YAML deserialization model ──────────────────────────────────────────
+
+ internal sealed class PackManifestYaml
+ {
+ public string? Name { get; set; }
+ public string? Version { get; set; }
+ public string? MinSteergenVersion { get; set; }
+ public string? Scope { get; set; }
+ public List? Targets { get; set; }
+ public List? ProvidedTargets { get; set; }
+ public string? RulesRoot { get; set; }
+ }
+
+ internal sealed class ProvidedTargetDefinitionYaml
+ {
+ public string? TargetId { get; set; }
+ public string? DefaultLayout { get; set; }
+ public string? Description { get; set; }
+ }
+}
diff --git a/src/Steergen.Core/Packs/PackScope.cs b/src/Steergen.Core/Packs/PackScope.cs
new file mode 100644
index 0000000..3f976c8
--- /dev/null
+++ b/src/Steergen.Core/Packs/PackScope.cs
@@ -0,0 +1,8 @@
+namespace Steergen.Core.Packs;
+
+public enum PackScope
+{
+ Global,
+ Supplemental,
+ Project
+}
diff --git a/src/Steergen.Core/Packs/PackType.cs b/src/Steergen.Core/Packs/PackType.cs
new file mode 100644
index 0000000..2aeedba
--- /dev/null
+++ b/src/Steergen.Core/Packs/PackType.cs
@@ -0,0 +1,7 @@
+namespace Steergen.Core.Packs;
+
+public enum PackType
+{
+ Template,
+ Rules
+}
diff --git a/src/Steergen.Core/Packs/PackVersionChecker.cs b/src/Steergen.Core/Packs/PackVersionChecker.cs
new file mode 100644
index 0000000..16c32ed
--- /dev/null
+++ b/src/Steergen.Core/Packs/PackVersionChecker.cs
@@ -0,0 +1,60 @@
+using System.Text.RegularExpressions;
+
+namespace Steergen.Core.Packs;
+
+///
+/// Provides version compatibility checking for pack manifests.
+/// Compatible means runningVersion >= minSteergenVersion using standard
+/// semver comparison (major.minor.patch).
+///
+public static partial class PackVersionChecker
+{
+ private static readonly Regex SemverPattern = SemverRegex();
+
+ ///
+ /// Returns if is
+ /// greater than or equal to using
+ /// standard semver comparison (major.minor.patch).
+ /// Returns if either version string is not a valid semver.
+ ///
+ public static bool IsCompatible(string runningVersion, string minSteergenVersion)
+ {
+ if (!TryParse(runningVersion, out var running) || !TryParse(minSteergenVersion, out var min))
+ return false;
+
+ return Compare(running, min) >= 0;
+ }
+
+ ///
+ /// Attempts to parse a version string in the format "major.minor.patch"
+ /// where each component is a non-negative integer.
+ ///
+ public static bool IsValidSemver(string version) =>
+ SemverPattern.IsMatch(version);
+
+ internal static bool TryParse(string version, out (int Major, int Minor, int Patch) result)
+ {
+ result = default;
+ var match = SemverPattern.Match(version);
+ if (!match.Success)
+ return false;
+
+ result = (
+ int.Parse(match.Groups[1].Value),
+ int.Parse(match.Groups[2].Value),
+ int.Parse(match.Groups[3].Value));
+ return true;
+ }
+
+ internal static int Compare((int Major, int Minor, int Patch) a, (int Major, int Minor, int Patch) b)
+ {
+ int cmp = a.Major.CompareTo(b.Major);
+ if (cmp != 0) return cmp;
+ cmp = a.Minor.CompareTo(b.Minor);
+ if (cmp != 0) return cmp;
+ return a.Patch.CompareTo(b.Patch);
+ }
+
+ [GeneratedRegex(@"^(\d+)\.(\d+)\.(\d+)$", RegexOptions.Compiled)]
+ private static partial Regex SemverRegex();
+}
diff --git a/src/Steergen.Core/Packs/ProvidedTargetDefinition.cs b/src/Steergen.Core/Packs/ProvidedTargetDefinition.cs
new file mode 100644
index 0000000..7b86636
--- /dev/null
+++ b/src/Steergen.Core/Packs/ProvidedTargetDefinition.cs
@@ -0,0 +1,18 @@
+namespace Steergen.Core.Packs;
+
+///
+/// Declares a complete target definition provided by a template pack.
+/// The pack supplies templates and a default layout; Steergen supplies
+/// the generic PackTargetComponent that delegates rendering.
+///
+public sealed record ProvidedTargetDefinition
+{
+ public required string TargetId { get; init; }
+
+ ///
+ /// Relative path to layout YAML within the pack directory.
+ ///
+ public required string DefaultLayout { get; init; }
+
+ public string? Description { get; init; }
+}
diff --git a/src/Steergen.Core/Packs/RulesPackConfiguration.cs b/src/Steergen.Core/Packs/RulesPackConfiguration.cs
new file mode 100644
index 0000000..3d43381
--- /dev/null
+++ b/src/Steergen.Core/Packs/RulesPackConfiguration.cs
@@ -0,0 +1,17 @@
+namespace Steergen.Core.Packs;
+
+///
+/// Configuration for a single rules pack entry, combining the GitHub source
+/// with an optional consumer scope override that takes precedence over the
+/// pack manifest's declared scope.
+///
+public sealed record RulesPackConfiguration
+{
+ public required GitHubPackSource Source { get; init; }
+
+ ///
+ /// When set, overrides the scope declared in the pack manifest,
+ /// allowing consumers to elevate or demote a pack's merge precedence.
+ ///
+ public PackScope? ScopeOverride { get; init; }
+}
diff --git a/src/Steergen.Core/Packs/RulesPackFileDiscovery.cs b/src/Steergen.Core/Packs/RulesPackFileDiscovery.cs
new file mode 100644
index 0000000..594d77d
--- /dev/null
+++ b/src/Steergen.Core/Packs/RulesPackFileDiscovery.cs
@@ -0,0 +1,44 @@
+namespace Steergen.Core.Packs;
+
+///
+/// Discovers Markdown files recursively under a rules pack root directory.
+/// Returns all and only files with the .md extension in deterministic
+/// ordinal sort order, excluding symbolic links.
+///
+public static class RulesPackFileDiscovery
+{
+ ///
+ /// Discovers all .md files recursively under ,
+ /// sorted by full path using ordinal string comparison, excluding symbolic links.
+ ///
+ /// The root directory to search recursively.
+ ///
+ /// An ordered list of absolute file paths for all .md files found,
+ /// excluding any that are symbolic links (reparse points).
+ ///
+ ///
+ /// Thrown when does not exist.
+ ///
+ public static IReadOnlyList DiscoverMarkdownFiles(string rulesRoot)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(rulesRoot);
+
+ if (!Directory.Exists(rulesRoot))
+ throw new DirectoryNotFoundException($"Rules root directory does not exist: '{rulesRoot}'");
+
+ return Directory
+ .EnumerateFiles(rulesRoot, "*.md", SearchOption.AllDirectories)
+ .Where(path => !IsSymbolicLink(path))
+ .OrderBy(path => path, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ ///
+ /// Determines whether the specified file is a symbolic link (reparse point).
+ ///
+ private static bool IsSymbolicLink(string filePath)
+ {
+ var attributes = File.GetAttributes(filePath);
+ return attributes.HasFlag(FileAttributes.ReparsePoint);
+ }
+}
diff --git a/src/Steergen.Core/Packs/RulesPackLoadResult.cs b/src/Steergen.Core/Packs/RulesPackLoadResult.cs
new file mode 100644
index 0000000..a50ce48
--- /dev/null
+++ b/src/Steergen.Core/Packs/RulesPackLoadResult.cs
@@ -0,0 +1,14 @@
+using Steergen.Core.Model;
+using Steergen.Core.Validation;
+
+namespace Steergen.Core.Packs;
+
+///
+/// Result of loading all configured rules packs. Contains the merged
+/// steering documents and any diagnostics encountered during loading.
+///
+public sealed record RulesPackLoadResult
+{
+ public IReadOnlyList Documents { get; init; } = [];
+ public IReadOnlyList Diagnostics { get; init; } = [];
+}
diff --git a/src/Steergen.Core/Packs/RulesPackLoader.cs b/src/Steergen.Core/Packs/RulesPackLoader.cs
new file mode 100644
index 0000000..340b9f2
--- /dev/null
+++ b/src/Steergen.Core/Packs/RulesPackLoader.cs
@@ -0,0 +1,228 @@
+using Steergen.Core.Model;
+using Steergen.Core.Parsing;
+using Steergen.Core.Validation;
+
+namespace Steergen.Core.Packs;
+
+///
+/// Discovers, parses, validates, and prepares rules pack documents for merge.
+/// Loads all rules from configured packs, applying scope and ordering.
+/// Returns documents tagged with source pack metadata.
+///
+public sealed class RulesPackLoader
+{
+ private const long MaxFileSizeBytes = 1_048_576; // 1 MB
+
+ private readonly PackManifestParser _manifestParser;
+ private readonly SteeringValidator _validator;
+
+ public RulesPackLoader(
+ PackManifestParser manifestParser,
+ SteeringValidator validator)
+ {
+ _manifestParser = manifestParser ?? throw new ArgumentNullException(nameof(manifestParser));
+ _validator = validator ?? throw new ArgumentNullException(nameof(validator));
+ }
+
+ ///
+ /// Loads all rules from configured packs, applying scope and ordering.
+ /// Returns documents tagged with source pack metadata.
+ ///
+ public RulesPackLoadResult Load(
+ IReadOnlyList packConfigs,
+ string cacheBaseDirectory,
+ string runningSteergenVersion)
+ {
+ var allDocuments = new List();
+ var allDiagnostics = new List();
+
+ foreach (var packConfig in packConfigs)
+ {
+ LoadSinglePack(packConfig, cacheBaseDirectory, runningSteergenVersion, allDocuments, allDiagnostics);
+ }
+
+ return new RulesPackLoadResult
+ {
+ Documents = allDocuments,
+ Diagnostics = allDiagnostics
+ };
+ }
+
+ private void LoadSinglePack(
+ RulesPackConfiguration packConfig,
+ string cacheBaseDirectory,
+ string runningSteergenVersion,
+ List allDocuments,
+ List allDiagnostics)
+ {
+ var source = packConfig.Source;
+
+ // Step a: Resolve cache path
+ var cachePath = ResolveCachePath(source, cacheBaseDirectory);
+
+ // Step b: If cache missing → emit error diagnostic, skip pack
+ if (!Directory.Exists(cachePath))
+ {
+ allDiagnostics.Add(new Diagnostic(
+ "RP005",
+ $"Rules pack '{source.Owner}/{source.Repo}' is not in the local cache. Run 'steergen update --rules' to download it.",
+ DiagnosticSeverity.Error));
+ return;
+ }
+
+ // Step c: Parse pack.yaml → validate manifest
+ var manifest = _manifestParser.Parse(cachePath);
+ if (manifest is null)
+ {
+ allDiagnostics.Add(new Diagnostic(
+ "RP001",
+ $"Rules pack at '{cachePath}' is missing pack.yaml manifest.",
+ DiagnosticSeverity.Error));
+ return;
+ }
+
+ var manifestDiagnostics = _manifestParser.Validate(manifest, PackType.Rules, runningSteergenVersion);
+ if (manifestDiagnostics.Count > 0)
+ {
+ allDiagnostics.AddRange(manifestDiagnostics);
+
+ // If there are errors (not just warnings), skip this pack
+ if (manifestDiagnostics.Any(d => d.Severity == DiagnosticSeverity.Error))
+ return;
+ }
+
+ // Step d: Determine effective scope: ScopeOverride ?? manifest.Scope
+ var effectiveScope = packConfig.ScopeOverride ?? manifest.Scope;
+ if (effectiveScope is null)
+ {
+ allDiagnostics.Add(new Diagnostic(
+ "RP002",
+ $"Rules pack '{manifest.Name}' has no effective scope (neither consumer override nor manifest scope).",
+ DiagnosticSeverity.Error));
+ return;
+ }
+
+ // Step e: Resolve rules root: manifest.RulesRoot ?? pack root
+ var rulesRoot = cachePath;
+ if (!string.IsNullOrWhiteSpace(manifest.RulesRoot))
+ {
+ rulesRoot = Path.Combine(cachePath, manifest.RulesRoot);
+ if (!Directory.Exists(rulesRoot))
+ {
+ allDiagnostics.Add(new Diagnostic(
+ "RP003",
+ $"Rules pack '{manifest.Name}' declares rulesRoot '{manifest.RulesRoot}' but the directory does not exist.",
+ DiagnosticSeverity.Error));
+ return;
+ }
+ }
+
+ // Step f: Enumerate *.md files recursively (ordinal sort, no symlink follow)
+ var mdFiles = EnumerateMarkdownFiles(rulesRoot);
+
+ // Steps g-j: Process each file
+ foreach (var filePath in mdFiles)
+ {
+ // Step g: Reject files > 1 MB
+ var fileInfo = new FileInfo(filePath);
+ if (fileInfo.Length > MaxFileSizeBytes)
+ {
+ allDiagnostics.Add(new Diagnostic(
+ "RP004",
+ $"Rules pack '{manifest.Name}': file '{filePath}' exceeds 1 MB size limit ({fileInfo.Length} bytes).",
+ DiagnosticSeverity.Error,
+ new SourceLocation(filePath, 0)));
+ continue;
+ }
+
+ // Step h: Parse each file with SteeringMarkdownParser
+ var content = File.ReadAllText(filePath);
+ var document = SteeringMarkdownParser.Parse(content, filePath);
+
+ // Step i: Validate with SteeringValidator
+ var validationDiagnostics = _validator.Validate(document);
+ if (validationDiagnostics.Count > 0)
+ {
+ allDiagnostics.AddRange(validationDiagnostics.Select(d => d with
+ {
+ Message = $"Rules pack '{manifest.Name}': {d.Message}"
+ }));
+ }
+
+ // Step j: Tag each rule with SourcePackName and effective scope
+ var taggedRules = document.Rules
+ .Select(rule => rule with
+ {
+ SourcePackName = manifest.Name,
+ SourcePackScope = effectiveScope.Value
+ })
+ .ToList();
+
+ var taggedDocument = document with { Rules = taggedRules };
+ allDocuments.Add(taggedDocument);
+ }
+ }
+
+ ///
+ /// Resolves the cache path for a rules pack source.
+ /// Format: {cacheBase}/rules/{owner}/{repo}/{ref}/
+ ///
+ private static string ResolveCachePath(GitHubPackSource source, string cacheBaseDirectory)
+ {
+ var refValue = source.Ref ?? "HEAD";
+ return Path.Combine(
+ cacheBaseDirectory,
+ "rules",
+ source.Owner,
+ source.Repo,
+ refValue) + Path.DirectorySeparatorChar;
+ }
+
+ ///
+ /// Enumerates all .md files recursively under the given root directory,
+ /// sorted in ordinal order, excluding symbolic links.
+ ///
+ private static IReadOnlyList EnumerateMarkdownFiles(string rootDirectory)
+ {
+ if (!Directory.Exists(rootDirectory))
+ return [];
+
+ var files = new List();
+ EnumerateRecursive(rootDirectory, files);
+ files.Sort(StringComparer.Ordinal);
+ return files;
+ }
+
+ ///
+ /// Recursively enumerates .md files without following symbolic links.
+ ///
+ private static void EnumerateRecursive(string directory, List results)
+ {
+ // Check if the directory itself is a symlink
+ var dirInfo = new DirectoryInfo(directory);
+ if (dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint))
+ return;
+
+ // Enumerate files in this directory
+ foreach (var file in Directory.GetFiles(directory, "*.md"))
+ {
+ var fileInfo = new FileInfo(file);
+ // Skip symbolic links
+ if (fileInfo.Attributes.HasFlag(FileAttributes.ReparsePoint))
+ continue;
+
+ results.Add(file);
+ }
+
+ // Recurse into subdirectories
+ foreach (var subDir in Directory.GetDirectories(directory))
+ {
+ var subDirInfo = new DirectoryInfo(subDir);
+ // Skip symbolic links
+ if (subDirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint))
+ continue;
+
+ EnumerateRecursive(subDir, results);
+ }
+ }
+}
diff --git a/src/Steergen.Core/Packs/ScopedPackDocuments.cs b/src/Steergen.Core/Packs/ScopedPackDocuments.cs
new file mode 100644
index 0000000..ebfab34
--- /dev/null
+++ b/src/Steergen.Core/Packs/ScopedPackDocuments.cs
@@ -0,0 +1,14 @@
+using Steergen.Core.Model;
+
+namespace Steergen.Core.Packs;
+
+///
+/// Groups steering documents from a rules pack by their effective scope.
+/// Used by the extended
+/// signature to apply scope-based merge precedence.
+///
+public sealed record ScopedPackDocuments
+{
+ public required PackScope Scope { get; init; }
+ public IReadOnlyList Documents { get; init; } = [];
+}
diff --git a/src/Steergen.Core/Packs/TemplatePackValidator.cs b/src/Steergen.Core/Packs/TemplatePackValidator.cs
new file mode 100644
index 0000000..372060a
--- /dev/null
+++ b/src/Steergen.Core/Packs/TemplatePackValidator.cs
@@ -0,0 +1,102 @@
+using Scriban;
+using Steergen.Core.Model;
+using Steergen.Core.Validation;
+
+namespace Steergen.Core.Packs;
+
+///
+/// Validates template pack content: Scriban syntax correctness and template file name
+/// conformance against known template names for declared target IDs.
+///
+public sealed class TemplatePackValidator
+{
+ ///
+ /// Known template names per built-in target ID.
+ /// External (pack-provided) targets use "document" as the default template name.
+ ///
+ private static readonly Dictionary> KnownTemplateNames =
+ new(StringComparer.Ordinal)
+ {
+ ["kiro"] = new HashSet(StringComparer.Ordinal) { "document" },
+ ["speckit"] = new HashSet(StringComparer.Ordinal) { "constitution", "module" },
+ ["agents"] = new HashSet(StringComparer.Ordinal) { "copilot.agent", "kiro.agent" },
+ ["copilot-agent"] = new HashSet(StringComparer.Ordinal) { "copilot.agent" },
+ ["kiro-agent"] = new HashSet(StringComparer.Ordinal) { "kiro.agent" },
+ };
+
+ ///
+ /// Validates that the given template content is parseable by the Scriban template engine.
+ /// Returns diagnostics for any syntax errors found.
+ ///
+ /// The template content string to validate.
+ /// The file path for diagnostic reporting.
+ /// A list of diagnostics. Empty if the template is valid.
+ public IReadOnlyList ValidateTemplateContent(string content, string filePath)
+ {
+ ArgumentNullException.ThrowIfNull(content);
+ ArgumentException.ThrowIfNullOrEmpty(filePath);
+
+ var template = Template.Parse(content, filePath);
+
+ if (!template.HasErrors)
+ return [];
+
+ return template.Messages
+ .Where(m => m.Type == Scriban.Parsing.ParserMessageType.Error)
+ .Select(m => new Diagnostic(
+ "TP003",
+ $"Scriban syntax error in '{filePath}' at line {m.Span.Start.Line}: {m.Message}",
+ DiagnosticSeverity.Error,
+ new SourceLocation(filePath, m.Span.Start.Line, m.Span.Start.Column)))
+ .ToList();
+ }
+
+ ///
+ /// Validates that a template file name matches a known template name for the given target ID.
+ /// Returns a warning diagnostic if the file name is not recognized.
+ ///
+ /// The template name (without .scriban extension).
+ /// The target ID the template is declared for.
+ /// A list of diagnostics. Empty if the template name is known.
+ public IReadOnlyList ValidateTemplateName(string templateName, string targetId)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(templateName);
+ ArgumentException.ThrowIfNullOrEmpty(targetId);
+
+ if (IsKnownTemplateName(templateName, targetId))
+ return [];
+
+ return
+ [
+ new Diagnostic(
+ "TP006",
+ $"Template file '{templateName}.scriban' does not match a known template name for target '{targetId}'.",
+ DiagnosticSeverity.Warning)
+ ];
+ }
+
+ ///
+ /// Returns true if the template name is a known template name for the given target ID.
+ /// Unknown target IDs (e.g., pack-provided targets) accept "document" as the default.
+ ///
+ public bool IsKnownTemplateName(string templateName, string targetId)
+ {
+ if (KnownTemplateNames.TryGetValue(targetId, out var knownNames))
+ return knownNames.Contains(templateName);
+
+ // For unknown/external targets, "document" is the conventional default
+ return string.Equals(templateName, "document", StringComparison.Ordinal);
+ }
+
+ ///
+ /// Returns the set of known template names for a given target ID.
+ /// Returns a set containing only "document" for unknown target IDs.
+ ///
+ public IReadOnlySet GetKnownTemplateNames(string targetId)
+ {
+ if (KnownTemplateNames.TryGetValue(targetId, out var knownNames))
+ return knownNames;
+
+ return new HashSet(StringComparer.Ordinal) { "document" };
+ }
+}
diff --git a/src/Steergen.Core/Steergen.Core.csproj b/src/Steergen.Core/Steergen.Core.csproj
index 59ca1af..5361955 100644
--- a/src/Steergen.Core/Steergen.Core.csproj
+++ b/src/Steergen.Core/Steergen.Core.csproj
@@ -5,10 +5,12 @@
+
+
-
+
diff --git a/src/Steergen.Core/Targets/PackTargetComponent.cs b/src/Steergen.Core/Targets/PackTargetComponent.cs
new file mode 100644
index 0000000..28db771
--- /dev/null
+++ b/src/Steergen.Core/Targets/PackTargetComponent.cs
@@ -0,0 +1,154 @@
+using Scriban;
+using Scriban.Runtime;
+using Steergen.Core.Generation;
+using Steergen.Core.Model;
+
+namespace Steergen.Core.Targets;
+
+///
+/// Generic target component for pack-provided targets.
+/// Delegates all rendering to the pack's Scriban templates and uses
+/// the pack's default layout YAML for routing.
+///
+public sealed class PackTargetComponent : ITargetComponent
+{
+ private readonly string _targetId;
+ private readonly ITemplateProvider _templateProvider;
+ private readonly string _defaultLayoutPath;
+ private readonly TargetDescriptor _descriptor;
+
+ public PackTargetComponent(
+ string targetId,
+ ITemplateProvider templateProvider,
+ string defaultLayoutPath,
+ string? packName = null,
+ string? description = null)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(targetId);
+ ArgumentNullException.ThrowIfNull(templateProvider);
+ ArgumentException.ThrowIfNullOrEmpty(defaultLayoutPath);
+
+ _targetId = targetId;
+ _templateProvider = templateProvider;
+ _defaultLayoutPath = defaultLayoutPath;
+ _descriptor = new TargetDescriptor(
+ targetId,
+ targetId,
+ description ?? $"Pack-provided target '{targetId}'.")
+ {
+ Origin = TargetOrigin.PackProvided,
+ PackName = packName
+ };
+ }
+
+ public string TargetId => _targetId;
+ public TargetDescriptor Descriptor => _descriptor;
+
+ ///
+ /// The filesystem path to the pack's default layout YAML.
+ /// Used by the generation pipeline to load the layout for routing.
+ ///
+ public string DefaultLayoutPath => _defaultLayoutPath;
+
+ public async Task GenerateWithPlanAsync(
+ ResolvedSteeringModel model,
+ TargetConfiguration config,
+ WritePlan writePlan,
+ CancellationToken cancellationToken)
+ {
+ var outputPath = config.OutputPath
+ ?? throw new InvalidOperationException(
+ $"Pack target '{_targetId}' requires OutputPath to be set.");
+
+ var ruleIndex = model.Rules.ToDictionary(r => r.Id ?? "", StringComparer.Ordinal);
+
+ foreach (var file in writePlan.Files)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var rules = file.AppendUnits
+ .Select(u => ruleIndex.TryGetValue(u.RuleId, out var r) ? r : null)
+ .Where(r => r is not null)
+ .Cast()
+ .ToList();
+
+ if (rules.Count == 0) continue;
+
+ var resolvedPath = PlannedOutputPathResolver.Resolve(
+ file.Path, outputPath, writePlan.GlobalRoot, writePlan.ProjectRoot);
+
+ var renderModel = BuildRenderModel(rules, resolvedPath, config.FormatOptions);
+ var rendered = await RenderAsync(renderModel, cancellationToken);
+
+ var outputDir = Path.GetDirectoryName(resolvedPath)!;
+ Directory.CreateDirectory(outputDir);
+ await File.WriteAllTextAsync(resolvedPath, rendered, cancellationToken);
+ }
+ }
+
+ ///
+ /// Builds the generic render model exposed to pack Scriban templates.
+ /// Exposes the same fields available to built-in targets: rules, targetId, filePath, formatOptions.
+ ///
+ private ScriptObject BuildRenderModel(
+ IReadOnlyList rules,
+ string filePath,
+ Dictionary formatOptions)
+ {
+ var ruleModels = rules.Select(r => new PackRuleModel
+ {
+ Id = r.Id ?? "",
+ Category = r.Category ?? "",
+ Mandatory = r.Mandatory,
+ Deprecated = r.Deprecated,
+ PrimaryText = r.PrimaryText ?? "",
+ ExplanatoryText = r.ExplanatoryText ?? "",
+ Tags = r.Tags,
+ InputFileStem = r.InputFileStem ?? "",
+ }).ToList();
+
+ var scriptObject = new ScriptObject();
+ scriptObject["rules"] = ruleModels;
+ scriptObject["target_id"] = _targetId;
+ scriptObject["file_path"] = filePath;
+ scriptObject["format_options"] = formatOptions;
+ return scriptObject;
+ }
+
+ private async Task RenderAsync(
+ ScriptObject renderModel,
+ CancellationToken cancellationToken)
+ {
+ var templateText = _templateProvider.GetTemplate(_targetId, "document");
+ var template = Template.Parse(templateText);
+
+ if (template.HasErrors)
+ {
+ var errors = string.Join("; ", template.Messages.Select(m => m.Message));
+ throw new InvalidOperationException(
+ $"Pack template for target '{_targetId}' has Scriban syntax errors: {errors}");
+ }
+
+ var context = new TemplateContext();
+ context.PushGlobal(renderModel);
+
+ var result = await template.RenderAsync(context);
+ return result;
+ }
+}
+
+///
+/// Rule model exposed to pack Scriban templates.
+/// Provides the same fields available to built-in target rule models.
+///
+public sealed record PackRuleModel
+{
+ public string Id { get; init; } = "";
+ public string Category { get; init; } = "";
+ public bool Mandatory { get; init; }
+ public bool Deprecated { get; init; }
+ public string PrimaryText { get; init; } = "";
+ public string ExplanatoryText { get; init; } = "";
+ public IReadOnlyList Tags { get; init; } = [];
+ public string InputFileStem { get; init; } = "";
+}
diff --git a/src/Steergen.Core/Targets/TargetDescriptor.cs b/src/Steergen.Core/Targets/TargetDescriptor.cs
index 2e21d02..0d4290d 100644
--- a/src/Steergen.Core/Targets/TargetDescriptor.cs
+++ b/src/Steergen.Core/Targets/TargetDescriptor.cs
@@ -1,3 +1,34 @@
namespace Steergen.Core.Targets;
-public record TargetDescriptor(string Id, string DisplayName, string Description);
+///
+/// Describes a registered target, including its origin (built-in or pack-provided).
+///
+public record TargetDescriptor(string Id, string DisplayName, string Description)
+{
+ ///
+ /// Alias for matching the design document naming.
+ ///
+ public string TargetId => Id;
+
+ ///
+ /// Indicates whether the target is built-in or provided by a template pack.
+ ///
+ public TargetOrigin Origin { get; init; } = TargetOrigin.BuiltIn;
+
+ ///
+ /// The name of the pack that provides this target, if is .
+ ///
+ public string? PackName { get; init; }
+}
+
+///
+/// Indicates the origin of a registered target.
+///
+public enum TargetOrigin
+{
+ /// Target is compiled into the Steergen binary.
+ BuiltIn,
+
+ /// Target is provided by an external template pack.
+ PackProvided
+}
diff --git a/src/Steergen.Core/Targets/TargetRegistry.cs b/src/Steergen.Core/Targets/TargetRegistry.cs
index b77bb92..8de2c40 100644
--- a/src/Steergen.Core/Targets/TargetRegistry.cs
+++ b/src/Steergen.Core/Targets/TargetRegistry.cs
@@ -1,3 +1,6 @@
+using Steergen.Core.Packs;
+using Steergen.Core.Validation;
+
namespace Steergen.Core.Targets;
public static class TargetRegistry
@@ -38,6 +41,85 @@ public static void RegisterBuiltins(ITemplateProvider templateProvider)
Register(new Agents.KiroAgentTargetComponent(templateProvider));
}
+ ///
+ /// Registers pack-provided targets from a loaded template pack manifest.
+ /// Only registers targets whose defaultLayout file exists within the pack directory.
+ /// Emits TP009 diagnostic for targets with missing layout files.
+ ///
+ /// Diagnostics for any targets that could not be registered.
+ public static IReadOnlyList RegisterPackTargets(
+ PackManifest manifest,
+ string packBasePath,
+ ITemplateProvider templateProvider)
+ {
+ var diagnostics = new List();
+
+ if (manifest.ProvidedTargets is null || manifest.ProvidedTargets.Count == 0)
+ return diagnostics;
+
+ lock (Lock)
+ {
+ foreach (var target in manifest.ProvidedTargets)
+ {
+ var layoutPath = Path.Combine(packBasePath, target.DefaultLayout);
+
+ if (!File.Exists(layoutPath))
+ {
+ diagnostics.Add(new Diagnostic(
+ "TP009",
+ $"Provided target '{target.TargetId}' declares defaultLayout '{target.DefaultLayout}' but the file does not exist in pack directory '{packBasePath}'.",
+ DiagnosticSeverity.Error));
+ continue;
+ }
+
+ if (Components.ContainsKey(target.TargetId))
+ {
+ diagnostics.Add(new Diagnostic(
+ "TP011",
+ $"A target with ID '{target.TargetId}' is already registered. Cannot register pack-provided target from '{manifest.Name}'.",
+ DiagnosticSeverity.Error));
+ continue;
+ }
+
+ var component = new PackTargetComponent(
+ target.TargetId,
+ templateProvider,
+ layoutPath,
+ manifest.Name,
+ target.Description);
+
+ Components[target.TargetId] = component;
+ }
+ }
+
+ return diagnostics;
+ }
+
+ ///
+ /// Returns true if the target is available (built-in or pack-provided).
+ ///
+ public static bool IsAvailable(string targetId)
+ {
+ lock (Lock)
+ {
+ return Components.ContainsKey(targetId);
+ }
+ }
+
+ ///
+ /// Returns all available targets: built-in + pack-provided.
+ ///
+ public static IReadOnlyList GetAvailableTargets()
+ {
+ lock (Lock)
+ {
+ return Components.Values
+ .OrderBy(c => c.TargetId, StringComparer.Ordinal)
+ .Select(c => c.Descriptor)
+ .ToList();
+ }
+ }
+
public static IReadOnlyList GetAll()
{
lock (Lock)
@@ -72,6 +154,35 @@ public static bool HasDefaultLayout(string targetId)
}
}
+ ///
+ /// Checks whether removing a pack would leave registered targets orphaned.
+ /// Returns TP010 diagnostics for any targets still registered that were provided by the pack.
+ ///
+ public static IReadOnlyList ValidatePackRemoval(
+ string packName,
+ IReadOnlyList registeredTargets)
+ {
+ var diagnostics = new List();
+
+ lock (Lock)
+ {
+ foreach (var targetId in registeredTargets)
+ {
+ if (Components.TryGetValue(targetId, out var component) &&
+ component.Descriptor.Origin == TargetOrigin.PackProvided &&
+ string.Equals(component.Descriptor.PackName, packName, StringComparison.Ordinal))
+ {
+ diagnostics.Add(new Diagnostic(
+ "TP010",
+ $"Target '{targetId}' is still registered but its providing pack '{packName}' is being removed. Remove the target first with 'steergen target remove {targetId}'.",
+ DiagnosticSeverity.Error));
+ }
+ }
+ }
+
+ return diagnostics;
+ }
+
internal static void Clear()
{
lock (Lock)
diff --git a/src/Steergen.Core/Targets/TemplateResolver.cs b/src/Steergen.Core/Targets/TemplateResolver.cs
new file mode 100644
index 0000000..c7fffed
--- /dev/null
+++ b/src/Steergen.Core/Targets/TemplateResolver.cs
@@ -0,0 +1,233 @@
+using Steergen.Core.Validation;
+
+namespace Steergen.Core.Targets;
+
+///
+/// Resolves Scriban templates using a three-level override precedence:
+/// 1. Local override path (templatePackPath in config)
+/// 2. Cached GitHub pack (downloaded to local pack cache)
+/// 3. Built-in embedded templates (EmbeddedTemplateProvider)
+///
+/// Template packs that declare a targets list are only consulted for
+/// those declared targets. Packs without a targets list apply to all targets.
+///
+public sealed class TemplateResolver : ITemplateProvider
+{
+ private readonly string? _localOverridePath;
+ private readonly string? _cachedPackPath;
+ private readonly ITemplateProvider _embeddedProvider;
+ private readonly IReadOnlySet? _declaredTargets;
+ private readonly long _maxFileSizeBytes;
+
+ ///
+ /// Creates a new with the specified override layers.
+ ///
+ ///
+ /// Path to the local template override directory. If non-null but the directory
+ /// does not exist on the filesystem, a is thrown
+ /// with diagnostic TP001 and exit code 2.
+ ///
+ ///
+ /// Path to the cached GitHub pack directory. May be null if no GitHub pack is configured.
+ ///
+ ///
+ /// The built-in embedded template provider used as the final fallback.
+ ///
+ ///
+ /// If non-null, restricts the local and cached layers to only serve templates
+ /// for the declared target IDs. If null, all targets are served (backward-compatible).
+ ///
+ ///
+ /// Maximum allowed file size in bytes. Files exceeding this limit are rejected
+ /// with diagnostic TP002. Defaults to 1 MB (1,048,576 bytes).
+ ///
+ public TemplateResolver(
+ string? localOverridePath,
+ string? cachedPackPath,
+ ITemplateProvider embeddedProvider,
+ IReadOnlySet? declaredTargets = null,
+ long maxFileSizeBytes = 1_048_576)
+ {
+ ArgumentNullException.ThrowIfNull(embeddedProvider);
+
+ // If localOverridePath is configured but does not exist, throw with TP001
+ if (localOverridePath is not null && !Directory.Exists(localOverridePath))
+ {
+ throw new TemplatePackException(
+ new Diagnostic(
+ "TP001",
+ $"Configured templatePackPath does not exist: '{localOverridePath}'",
+ DiagnosticSeverity.Error),
+ ExitCode: 2);
+ }
+
+ _localOverridePath = localOverridePath;
+ _cachedPackPath = cachedPackPath;
+ _embeddedProvider = embeddedProvider;
+ _declaredTargets = declaredTargets;
+ _maxFileSizeBytes = maxFileSizeBytes;
+ }
+
+ ///
+ /// Returns the template content for the given target and template name,
+ /// resolved using the three-level override precedence.
+ ///
+ public string GetTemplate(string targetId, string templateName)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(targetId);
+ ArgumentException.ThrowIfNullOrEmpty(templateName);
+
+ // Check if the target is in the declared targets set (if filtering is active)
+ var targetInScope = IsTargetInScope(targetId);
+
+ // Layer 1: Local override path
+ if (targetInScope && _localOverridePath is not null)
+ {
+ var localContent = TryReadTemplateFile(_localOverridePath, targetId, templateName);
+ if (localContent is not null)
+ return localContent;
+ }
+
+ // Layer 2: Cached GitHub pack
+ if (targetInScope && _cachedPackPath is not null)
+ {
+ var cachedContent = TryReadTemplateFile(_cachedPackPath, targetId, templateName);
+ if (cachedContent is not null)
+ return cachedContent;
+ }
+
+ // Layer 3: Built-in embedded templates (always available, no target scoping)
+ return _embeddedProvider.GetTemplate(targetId, templateName);
+ }
+
+ ///
+ /// Returns the source layer that would provide the template.
+ /// Used by steergen inspect --templates.
+ ///
+ public TemplateSource GetTemplateSource(string targetId, string templateName)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(targetId);
+ ArgumentException.ThrowIfNullOrEmpty(templateName);
+
+ var targetInScope = IsTargetInScope(targetId);
+
+ // Layer 1: Local override path
+ if (targetInScope && _localOverridePath is not null)
+ {
+ if (TemplateFileExists(_localOverridePath, targetId, templateName))
+ return TemplateSource.LocalOverride;
+ }
+
+ // Layer 2: Cached GitHub pack
+ if (targetInScope && _cachedPackPath is not null)
+ {
+ if (TemplateFileExists(_cachedPackPath, targetId, templateName))
+ return TemplateSource.CachedGitHubPack;
+ }
+
+ // Layer 3: Built-in embedded
+ return TemplateSource.BuiltInEmbedded;
+ }
+
+ ///
+ /// Returns true if this resolver can provide templates for the given target.
+ /// A resolver with no declared targets can provide for any target.
+ ///
+ public bool ProvidesForTarget(string targetId)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(targetId);
+
+ // If no declared targets, we provide for all targets
+ if (_declaredTargets is null)
+ return true;
+
+ return _declaredTargets.Contains(targetId);
+ }
+
+ ///
+ /// Determines whether the target is in scope for the local/cached layers.
+ ///
+ private bool IsTargetInScope(string targetId)
+ {
+ // If no declared targets, all targets are in scope
+ if (_declaredTargets is null)
+ return true;
+
+ return _declaredTargets.Contains(targetId);
+ }
+
+ ///
+ /// Attempts to read a template file from the given base path.
+ /// Returns null if the file does not exist or is a symbolic link.
+ /// Throws if the file exceeds the maximum size limit.
+ ///
+ private string? TryReadTemplateFile(string basePath, string targetId, string templateName)
+ {
+ var filePath = ComputeTemplatePath(basePath, targetId, templateName);
+
+ if (!File.Exists(filePath))
+ return null;
+
+ // Do not follow symbolic links (check FileAttributes before reading)
+ var attributes = File.GetAttributes(filePath);
+ if (attributes.HasFlag(FileAttributes.ReparsePoint))
+ return null;
+
+ // Reject files > max size
+ var fileInfo = new FileInfo(filePath);
+ if (fileInfo.Length > _maxFileSizeBytes)
+ {
+ throw new TemplatePackException(
+ new Diagnostic(
+ "TP002",
+ $"Template file exceeds {_maxFileSizeBytes} byte size limit: '{filePath}' ({fileInfo.Length} bytes)",
+ DiagnosticSeverity.Error),
+ ExitCode: 2);
+ }
+
+ return File.ReadAllText(filePath);
+ }
+
+ ///
+ /// Checks whether a template file exists at the given base path without reading it.
+ /// Returns false for symbolic links.
+ ///
+ private static bool TemplateFileExists(string basePath, string targetId, string templateName)
+ {
+ var filePath = ComputeTemplatePath(basePath, targetId, templateName);
+
+ if (!File.Exists(filePath))
+ return false;
+
+ // Do not follow symbolic links
+ var attributes = File.GetAttributes(filePath);
+ return !attributes.HasFlag(FileAttributes.ReparsePoint);
+ }
+
+ ///
+ /// Computes the template file path using ordinal file path comparison.
+ /// Path format: {basePath}/{targetId}/{templateName}.scriban
+ ///
+ private static string ComputeTemplatePath(string basePath, string targetId, string templateName)
+ {
+ // Use Path.Combine for deterministic path construction with ordinal comparison
+ return Path.Combine(basePath, targetId, $"{templateName}.scriban");
+ }
+}
+
+///
+/// Exception thrown when a template pack configuration error is detected.
+/// Carries a diagnostic and an exit code for CLI reporting.
+///
+public sealed class TemplatePackException : Exception
+{
+ public Diagnostic Diagnostic { get; }
+ public int ExitCode { get; }
+
+ public TemplatePackException(Diagnostic diagnostic, int ExitCode)
+ : base(diagnostic.Message)
+ {
+ Diagnostic = diagnostic;
+ this.ExitCode = ExitCode;
+ }
+}
diff --git a/src/Steergen.Core/Targets/TemplateSource.cs b/src/Steergen.Core/Targets/TemplateSource.cs
new file mode 100644
index 0000000..01f1834
--- /dev/null
+++ b/src/Steergen.Core/Targets/TemplateSource.cs
@@ -0,0 +1,9 @@
+namespace Steergen.Core.Targets;
+
+public enum TemplateSource
+{
+ LocalOverride,
+ CachedGitHubPack,
+ BuiltInEmbedded,
+ ProvidedTarget
+}
diff --git a/tests/CheckScriban/CheckScriban.csproj b/tests/CheckScriban/CheckScriban.csproj
new file mode 100644
index 0000000..bc2c2a9
--- /dev/null
+++ b/tests/CheckScriban/CheckScriban.csproj
@@ -0,0 +1,9 @@
+
+
+ Exe
+ net10.0
+
+
+
+
+
diff --git a/tests/Steergen.Benchmarks/ScalabilityEnvelopeBenchmarks.cs b/tests/Steergen.Benchmarks/ScalabilityEnvelopeBenchmarks.cs
index 27bcaa5..0f79cc2 100644
--- a/tests/Steergen.Benchmarks/ScalabilityEnvelopeBenchmarks.cs
+++ b/tests/Steergen.Benchmarks/ScalabilityEnvelopeBenchmarks.cs
@@ -86,14 +86,14 @@ public IReadOnlyList ValidateBeyondEnvelopeCorpus()
[Benchmark]
public ResolvedSteeringModel ResolveEnvelopeModel()
{
- return _resolver.Resolve(_envelopeDocuments, [], ["default"]);
+ return _resolver.Resolve(_envelopeDocuments, Array.Empty(), ["default"]);
}
/// Resolve the steering model from 200 beyond-envelope documents (2,000 rules).
[Benchmark]
public ResolvedSteeringModel ResolveBeyondEnvelopeModel()
{
- return _resolver.Resolve(_beyondEnvelopeDocuments, [], ["default"]);
+ return _resolver.Resolve(_beyondEnvelopeDocuments, Array.Empty(), ["default"]);
}
// ── Helpers ───────────────────────────────────────────────────────────
diff --git a/tests/Steergen.Cli.IntegrationTests/ConstitutionProvenanceTests.cs b/tests/Steergen.Cli.IntegrationTests/ConstitutionProvenanceTests.cs
index d3fe156..0585f9b 100644
--- a/tests/Steergen.Cli.IntegrationTests/ConstitutionProvenanceTests.cs
+++ b/tests/Steergen.Cli.IntegrationTests/ConstitutionProvenanceTests.cs
@@ -29,7 +29,6 @@ private static async Task WriteConfigAsync(string dir, string? templateP
var path = Path.Combine(dir, "steergen.config.yaml");
var config = new SteeringConfiguration
{
- GlobalRoot = Path.Combine(dir, "steering", "global"),
ProjectRoot = Path.Combine(dir, "steering", "project"),
TemplatePackVersion = templatePackVersion,
};
diff --git a/tests/Steergen.Cli.IntegrationTests/ExternalTargetPackTests.cs b/tests/Steergen.Cli.IntegrationTests/ExternalTargetPackTests.cs
new file mode 100644
index 0000000..6efe47f
--- /dev/null
+++ b/tests/Steergen.Cli.IntegrationTests/ExternalTargetPackTests.cs
@@ -0,0 +1,497 @@
+using Steergen.Cli.Commands;
+using Steergen.Core.Configuration;
+using Steergen.Core.Model;
+using Steergen.Core.Packs;
+using Steergen.Core.Targets;
+
+namespace Steergen.Cli.IntegrationTests;
+
+[Collection("CliOutput")]
+///
+/// Integration tests for external target packs (pack-provided targets).
+/// Validates: Requirements 16.3, 16.5, 16.6, 16.8
+///
+public sealed class ExternalTargetPackTests : IDisposable
+{
+ private static readonly string FixturesRoot =
+ Path.GetFullPath(Path.Combine(
+ AppContext.BaseDirectory,
+ "..", "..", "..", "..", "..", "tests", "Fixtures", "RealisticGovernance"));
+
+ public ExternalTargetPackTests()
+ {
+ TargetRegistry.Clear();
+ TargetRegistry.RegisterBuiltins(new StubTemplateProvider());
+ }
+
+ public void Dispose()
+ {
+ TargetRegistry.Clear();
+ }
+
+ private sealed class StubTemplateProvider : ITemplateProvider
+ {
+ public string GetTemplate(string targetId, string templateName) =>
+ string.Empty;
+ }
+
+ private static string MakeTempDir() =>
+ Directory.CreateTempSubdirectory("extpack-test-").FullName;
+
+ ///
+ /// Creates a template pack directory with a pack.yaml manifest declaring
+ /// a provided target, a default layout YAML, and a Scriban document template.
+ ///
+ private static string CreateTemplatePack(
+ string baseDir,
+ string packName = "test-external-pack",
+ string targetId = "custom-ext")
+ {
+ var packDir = Path.Combine(baseDir, "template-pack");
+ Directory.CreateDirectory(packDir);
+
+ // Write pack.yaml manifest with providedTargets
+ var packYaml = $"""
+ name: "{packName}"
+ version: "1.0.0"
+ minSteergenVersion: "0.1.0"
+ providedTargets:
+ - targetId: "{targetId}"
+ defaultLayout: "{targetId}/default-layout.yaml"
+ description: "Integration test external target"
+ """;
+ File.WriteAllText(Path.Combine(packDir, "pack.yaml"), packYaml);
+
+ // Create target subdirectory with default layout and template
+ var targetDir = Path.Combine(packDir, targetId);
+ Directory.CreateDirectory(targetDir);
+
+ WriteDefaultLayout(targetDir, targetId);
+ WriteDocumentTemplate(targetDir);
+
+ return packDir;
+ }
+
+ private static void WriteDefaultLayout(string targetDir, string targetId)
+ {
+ // Use a simple layout that routes all rules to a single file
+ var layoutYaml = @"version: ""1.0""
+
+roots:
+ globalRoot: ""${globalRoot}""
+ projectRoot: ""${projectRoot}""
+ targetRoot: ""${generationRoot}/" + targetId + @"-output""
+
+routes:
+ - id: all-rules
+ scope: both
+ explicit: true
+ anchor: core
+ order: 10
+ match:
+ category: ""*""
+ destination:
+ directory: ""${targetRoot}""
+ fileName: ""rules""
+ extension: "".md""
+
+fallback:
+ mode: other-at-core-anchor
+ fileBaseName: rules
+ directory: ""${targetRoot}""
+
+purge:
+ roots:
+ - ""${targetRoot}""
+ globs:
+ - ""**/*.md""
+";
+ File.WriteAllText(
+ Path.Combine(targetDir, "default-layout.yaml"), layoutYaml);
+ }
+
+ private static void WriteDocumentTemplate(string targetDir)
+ {
+ var template = @"# External Target Output
+{{- for rule in rules }}
+- {{ rule.id }}: {{ rule.primary_text }}
+{{- end }}
+";
+ File.WriteAllText(
+ Path.Combine(targetDir, "document.scriban"), template);
+ }
+
+ ///
+ /// Places a template pack in the user's real cache location
+ /// (~/.steergen/packs/{owner}/{repo}/{ref}/) so that RunCommand
+ /// can discover it via the GitHub source config path.
+ /// Returns the source string, ref, and cache path for cleanup.
+ ///
+ private static (string Source, string Ref, string CachePath) SetupCachedPack(
+ string packName = "test-external-pack",
+ string targetId = "custom-ext")
+ {
+ const string owner = "steergen-inttest";
+ const string repo = "ext-target-pack";
+ const string refValue = "v1.0.0-test";
+
+ var cacheBase = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".steergen");
+ var cachePath = Path.Combine(
+ cacheBase, "packs", owner, repo, refValue);
+
+ // Clean up any previous test run
+ if (Directory.Exists(cachePath))
+ Directory.Delete(cachePath, recursive: true);
+
+ Directory.CreateDirectory(cachePath);
+
+ // Write pack.yaml manifest with providedTargets
+ var packYaml = $"""
+ name: "{packName}"
+ version: "1.0.0"
+ minSteergenVersion: "0.1.0"
+ providedTargets:
+ - targetId: "{targetId}"
+ defaultLayout: "{targetId}/default-layout.yaml"
+ description: "Integration test external target"
+ """;
+ File.WriteAllText(Path.Combine(cachePath, "pack.yaml"), packYaml);
+
+ // Create target subdirectory with layout and template
+ var targetDir = Path.Combine(cachePath, targetId);
+ Directory.CreateDirectory(targetDir);
+ WriteDefaultLayout(targetDir, targetId);
+ WriteDocumentTemplate(targetDir);
+
+ return ($"github:{owner}/{repo}", refValue, cachePath);
+ }
+
+ private static void CleanupCachedPack(string cachePath)
+ {
+ if (Directory.Exists(cachePath))
+ Directory.Delete(cachePath, recursive: true);
+ }
+
+ private static async Task WriteConfigWithGitHubPackAsync(
+ string dir,
+ string source,
+ string refValue,
+ IEnumerable? registeredTargets = null)
+ {
+ var configPath = Path.Combine(dir, "steergen.config.yaml");
+ var writer = new SteergenConfigWriter();
+ var config = new SteeringConfiguration
+ {
+ ProjectRoot = Path.Combine(FixturesRoot, "project"),
+ RegisteredTargets = (registeredTargets ?? []).ToList(),
+ TemplatePack = new TemplatePackConfig
+ {
+ Source = source,
+ Ref = refValue,
+ },
+ };
+ await writer.WriteAsync(configPath, config);
+ return configPath;
+ }
+
+ private static async Task WriteConfigWithLocalPackAsync(
+ string dir,
+ string localPackPath,
+ IEnumerable? registeredTargets = null)
+ {
+ var configPath = Path.Combine(dir, "steergen.config.yaml");
+ var writer = new SteergenConfigWriter();
+ var config = new SteeringConfiguration
+ {
+ ProjectRoot = Path.Combine(FixturesRoot, "project"),
+ RegisteredTargets = (registeredTargets ?? []).ToList(),
+ TemplatePack = new TemplatePackConfig
+ {
+ LocalPath = localPackPath,
+ },
+ };
+ await writer.WriteAsync(configPath, config);
+ return configPath;
+ }
+
+ // ── Test: steergen target add with pack-provided target succeeds ─────────
+
+ [Fact]
+ public async Task TargetAdd_PackProvidedTarget_ReturnsExitCode0()
+ {
+ var workDir = MakeTempDir();
+ try
+ {
+ var packDir = CreateTemplatePack(workDir);
+
+ // Register pack targets so TargetRegistry.IsAvailable returns true
+ var manifestParser = new PackManifestParser();
+ var manifest = manifestParser.Parse(packDir)!;
+ var templateProvider = new TemplateResolver(
+ packDir, null, new StubTemplateProvider());
+ TargetRegistry.RegisterPackTargets(manifest, packDir, templateProvider);
+
+ var configPath = await WriteConfigWithLocalPackAsync(
+ workDir, packDir);
+
+ var exitCode = await TargetCommand.AddAsync(configPath, "custom-ext");
+
+ Assert.Equal(0, exitCode);
+
+ // Verify target was persisted to config
+ var loader = new SteergenConfigLoader();
+ var loaded = await loader.LoadAsync(configPath);
+ Assert.Contains("custom-ext", loaded.RegisteredTargets);
+ }
+ finally { Directory.Delete(workDir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task TargetAdd_PackProvidedTarget_IsAvailableReturnsTrue()
+ {
+ var workDir = MakeTempDir();
+ try
+ {
+ var packDir = CreateTemplatePack(workDir);
+
+ var manifestParser = new PackManifestParser();
+ var manifest = manifestParser.Parse(packDir)!;
+ var templateProvider = new TemplateResolver(
+ packDir, null, new StubTemplateProvider());
+ TargetRegistry.RegisterPackTargets(manifest, packDir, templateProvider);
+
+ Assert.True(TargetRegistry.IsAvailable("custom-ext"));
+ }
+ finally { Directory.Delete(workDir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task TargetAdd_UnregisteredPackTarget_ReturnsExitCode2()
+ {
+ var workDir = MakeTempDir();
+ try
+ {
+ // Do NOT register pack targets — the target should not be available
+ var configPath = await WriteConfigWithLocalPackAsync(
+ workDir, Path.Combine(workDir, "nonexistent-pack"));
+
+ var exitCode = await TargetCommand.AddAsync(
+ configPath, "unknown-pack-target");
+
+ Assert.Equal(2, exitCode);
+ }
+ finally { Directory.Delete(workDir, recursive: true); }
+ }
+
+ // ── Test: steergen run with external target renders via pack templates ───
+
+ [Fact]
+ public async Task Run_WithExternalTarget_ReturnsExitCode0()
+ {
+ var workDir = MakeTempDir();
+ var (source, refValue, cachePath) = SetupCachedPack();
+ try
+ {
+ var outputDir = Path.Combine(workDir, "output");
+ Directory.CreateDirectory(outputDir);
+
+ var configPath = await WriteConfigWithGitHubPackAsync(
+ workDir, source, refValue,
+ registeredTargets: ["custom-ext"]);
+
+ var exitCode = await RunCommand.RunAsync(
+ configPath: configPath,
+ globalRoot: null,
+ projectRoot: Path.Combine(FixturesRoot, "project"),
+ outputBase: outputDir,
+ explicitTargets: ["custom-ext"],
+ quiet: true,
+ cancellationToken: default);
+
+ Assert.Equal(0, exitCode);
+ }
+ finally
+ {
+ Directory.Delete(workDir, recursive: true);
+ CleanupCachedPack(cachePath);
+ }
+ }
+
+ [Fact]
+ public async Task Run_WithExternalTarget_ProducesOutputFiles()
+ {
+ var workDir = MakeTempDir();
+ var (source, refValue, cachePath) = SetupCachedPack();
+ try
+ {
+ var outputDir = Path.Combine(workDir, "output");
+ Directory.CreateDirectory(outputDir);
+
+ var configPath = await WriteConfigWithGitHubPackAsync(
+ workDir, source, refValue,
+ registeredTargets: ["custom-ext"]);
+
+ await RunCommand.RunAsync(
+ configPath: configPath,
+ globalRoot: null,
+ projectRoot: Path.Combine(FixturesRoot, "project"),
+ outputBase: outputDir,
+ explicitTargets: ["custom-ext"],
+ quiet: true,
+ cancellationToken: default);
+
+ var outputFiles = Directory.GetFiles(
+ outputDir, "*.md", SearchOption.AllDirectories);
+ Assert.True(outputFiles.Length > 0,
+ "External target should produce at least one output file");
+ }
+ finally
+ {
+ Directory.Delete(workDir, recursive: true);
+ CleanupCachedPack(cachePath);
+ }
+ }
+
+ [Fact]
+ public async Task Run_WithExternalTarget_RendersRulesViaPackTemplate()
+ {
+ var workDir = MakeTempDir();
+ var (source, refValue, cachePath) = SetupCachedPack();
+ try
+ {
+ var outputDir = Path.Combine(workDir, "output");
+ Directory.CreateDirectory(outputDir);
+
+ var configPath = await WriteConfigWithGitHubPackAsync(
+ workDir, source, refValue,
+ registeredTargets: ["custom-ext"]);
+
+ await RunCommand.RunAsync(
+ configPath: configPath,
+ globalRoot: null,
+ projectRoot: Path.Combine(FixturesRoot, "project"),
+ outputBase: outputDir,
+ explicitTargets: ["custom-ext"],
+ quiet: true,
+ cancellationToken: default);
+
+ var outputFiles = Directory.GetFiles(
+ outputDir, "*.md", SearchOption.AllDirectories);
+ Assert.True(outputFiles.Length > 0,
+ "Expected output files from external target");
+
+ var content = File.ReadAllText(outputFiles[0]);
+ // The template renders "# External Target Output" header
+ // followed by rules as "- {id}: {primary_text}"
+ Assert.Contains("# External Target Output", content);
+ }
+ finally
+ {
+ Directory.Delete(workDir, recursive: true);
+ CleanupCachedPack(cachePath);
+ }
+ }
+
+ // ── Test: removal of pack providing registered target emits TP010 ────────
+
+ [Fact]
+ public async Task TemplatePackRemove_WithRegisteredPackTarget_ReturnsExitCode2()
+ {
+ var workDir = MakeTempDir();
+ try
+ {
+ var packDir = CreateTemplatePack(workDir);
+
+ // Register pack targets in the registry
+ var manifestParser = new PackManifestParser();
+ var manifest = manifestParser.Parse(packDir)!;
+ var templateProvider = new TemplateResolver(
+ packDir, null, new StubTemplateProvider());
+ TargetRegistry.RegisterPackTargets(manifest, packDir, templateProvider);
+
+ // Write config with the template pack AND the target registered
+ var configPath = await WriteConfigWithLocalPackAsync(
+ workDir, packDir, registeredTargets: ["custom-ext"]);
+
+ // Attempt to remove the template pack — should fail with TP010
+ var exitCode = await TemplatePackRemoveCommand.RunAsync(configPath);
+
+ Assert.Equal(2, exitCode);
+ }
+ finally { Directory.Delete(workDir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task TemplatePackRemove_WithRegisteredPackTarget_EmitsOrphanedTargetError()
+ {
+ var workDir = MakeTempDir();
+ try
+ {
+ var packDir = CreateTemplatePack(workDir);
+
+ // Register pack targets in the registry
+ var manifestParser = new PackManifestParser();
+ var manifest = manifestParser.Parse(packDir)!;
+ var templateProvider = new TemplateResolver(
+ packDir, null, new StubTemplateProvider());
+ TargetRegistry.RegisterPackTargets(manifest, packDir, templateProvider);
+
+ // Write config with the template pack AND the target registered
+ var configPath = await WriteConfigWithLocalPackAsync(
+ workDir, packDir, registeredTargets: ["custom-ext"]);
+
+ // Capture stderr output
+ var originalErr = Console.Error;
+ using var errWriter = new StringWriter();
+ Console.SetError(errWriter);
+ try
+ {
+ await TemplatePackRemoveCommand.RunAsync(configPath);
+ }
+ finally
+ {
+ Console.SetError(originalErr);
+ }
+
+ var errOutput = errWriter.ToString();
+ // TP010 diagnostic message mentions the target and pack name
+ Assert.Contains("custom-ext", errOutput);
+ Assert.Contains("test-external-pack", errOutput);
+ Assert.Contains("steergen target remove", errOutput);
+ }
+ finally { Directory.Delete(workDir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task TemplatePackRemove_AfterTargetRemoved_Succeeds()
+ {
+ var workDir = MakeTempDir();
+ try
+ {
+ var packDir = CreateTemplatePack(workDir);
+
+ // Register pack targets in the registry
+ var manifestParser = new PackManifestParser();
+ var manifest = manifestParser.Parse(packDir)!;
+ var templateProvider = new TemplateResolver(
+ packDir, null, new StubTemplateProvider());
+ TargetRegistry.RegisterPackTargets(manifest, packDir, templateProvider);
+
+ // Write config with the template pack AND the target registered
+ var configPath = await WriteConfigWithLocalPackAsync(
+ workDir, packDir, registeredTargets: ["custom-ext"]);
+
+ // First remove the target from config
+ await TargetCommand.RemoveAsync(configPath, "custom-ext");
+
+ // Now remove the template pack — should succeed since target
+ // is no longer in registeredTargets
+ var exitCode = await TemplatePackRemoveCommand.RunAsync(configPath);
+
+ Assert.Equal(0, exitCode);
+ }
+ finally { Directory.Delete(workDir, recursive: true); }
+ }
+}
diff --git a/tests/Steergen.Cli.IntegrationTests/GlobalRootDeprecationIntegrationTests.cs b/tests/Steergen.Cli.IntegrationTests/GlobalRootDeprecationIntegrationTests.cs
new file mode 100644
index 0000000..968b837
--- /dev/null
+++ b/tests/Steergen.Cli.IntegrationTests/GlobalRootDeprecationIntegrationTests.cs
@@ -0,0 +1,128 @@
+using Steergen.Cli.Commands;
+
+namespace Steergen.Cli.IntegrationTests;
+
+[Collection("CliOutput")]
+
+///
+/// Integration tests for globalRoot deprecation (CFG001).
+/// Validates: Requirement 8.2
+///
+/// When globalRoot is present in steergen.config.yaml,
+/// steergen run must emit diagnostic CFG001 and exit with code 2.
+///
+public sealed class GlobalRootDeprecationIntegrationTests : IDisposable
+{
+ private readonly string _workDir;
+
+ public GlobalRootDeprecationIntegrationTests()
+ {
+ _workDir = Directory.CreateTempSubdirectory("globalroot-deprecation-").FullName;
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_workDir))
+ Directory.Delete(_workDir, recursive: true);
+ }
+
+ [Fact]
+ public async Task Run_WithGlobalRootInConfig_ReturnsExitCode2()
+ {
+ // Arrange — config file containing the deprecated globalRoot field
+ var configPath = Path.Combine(_workDir, "steergen.config.yaml");
+ await File.WriteAllTextAsync(configPath, """
+ globalRoot: /some/old/global/rules
+ projectRoot: ./steering
+ registeredTargets:
+ - speckit
+ """);
+
+ var outputDir = Path.Combine(_workDir, "output");
+ Directory.CreateDirectory(outputDir);
+
+ // Act
+ var exitCode = await RunCommand.RunAsync(
+ configPath: configPath,
+ globalRoot: null,
+ projectRoot: null,
+ outputBase: outputDir,
+ explicitTargets: [],
+ quiet: true,
+ cancellationToken: default);
+
+ // Assert — CFG001 causes exit code 2
+ Assert.Equal(2, exitCode);
+ }
+
+ [Fact]
+ public async Task Run_WithGlobalRootInConfig_EmitsCFG001ToStderr()
+ {
+ // Arrange
+ var configPath = Path.Combine(_workDir, "steergen.config.yaml");
+ await File.WriteAllTextAsync(configPath, """
+ globalRoot: /legacy/path
+ projectRoot: ./steering
+ """);
+
+ var outputDir = Path.Combine(_workDir, "output");
+ Directory.CreateDirectory(outputDir);
+
+ // Capture stderr
+ var originalStderr = Console.Error;
+ using var stderrWriter = new StringWriter();
+ Console.SetError(stderrWriter);
+
+ try
+ {
+ // Act
+ await RunCommand.RunAsync(
+ configPath: configPath,
+ globalRoot: null,
+ projectRoot: null,
+ outputBase: outputDir,
+ explicitTargets: [],
+ quiet: true,
+ cancellationToken: default);
+
+ var stderrOutput = stderrWriter.ToString();
+
+ // Assert — stderr contains CFG001 diagnostic
+ Assert.Contains("CFG001", stderrOutput);
+ Assert.Contains("globalRoot", stderrOutput);
+ }
+ finally
+ {
+ Console.SetError(originalStderr);
+ }
+ }
+
+ [Fact]
+ public async Task Run_WithGlobalRootInConfig_DoesNotProduceOutputFiles()
+ {
+ // Arrange
+ var configPath = Path.Combine(_workDir, "steergen.config.yaml");
+ await File.WriteAllTextAsync(configPath, """
+ globalRoot: /old/path
+ projectRoot: ./steering
+ registeredTargets:
+ - kiro
+ """);
+
+ var outputDir = Path.Combine(_workDir, "output");
+ Directory.CreateDirectory(outputDir);
+
+ // Act
+ await RunCommand.RunAsync(
+ configPath: configPath,
+ globalRoot: null,
+ projectRoot: null,
+ outputBase: outputDir,
+ explicitTargets: [],
+ quiet: true,
+ cancellationToken: default);
+
+ // Assert — no files generated because the command aborted early
+ Assert.Empty(Directory.GetFiles(outputDir, "*", SearchOption.AllDirectories));
+ }
+}
diff --git a/tests/Steergen.Cli.IntegrationTests/InitCommandTests.cs b/tests/Steergen.Cli.IntegrationTests/InitCommandTests.cs
index d4944ee..331bb79 100644
--- a/tests/Steergen.Cli.IntegrationTests/InitCommandTests.cs
+++ b/tests/Steergen.Cli.IntegrationTests/InitCommandTests.cs
@@ -66,7 +66,6 @@ public async Task Init_SingleValidTarget_WritesBootstrapConfigFile()
var loader = new SteergenConfigLoader();
var config = await loader.LoadAsync(Path.Combine(root, "steergen.config.yaml"));
- Assert.Equal(Path.Combine(root, "steering", "global"), config.GlobalRoot);
Assert.Equal(Path.Combine(root, "steering", "project"), config.ProjectRoot);
Assert.Equal(["speckit"], config.RegisteredTargets);
}
diff --git a/tests/Steergen.Cli.IntegrationTests/InspectCommandTests.cs b/tests/Steergen.Cli.IntegrationTests/InspectCommandTests.cs
index 1fe1e24..b930236 100644
--- a/tests/Steergen.Cli.IntegrationTests/InspectCommandTests.cs
+++ b/tests/Steergen.Cli.IntegrationTests/InspectCommandTests.cs
@@ -200,7 +200,6 @@ await writer.WriteAsync(
Path.Combine(dir, "steergen.config.yaml"),
new SteeringConfiguration
{
- GlobalRoot = globalRoot,
ProjectRoot = projectRoot,
});
}
diff --git a/tests/Steergen.Cli.IntegrationTests/RulesPackCommandsTests.cs b/tests/Steergen.Cli.IntegrationTests/RulesPackCommandsTests.cs
new file mode 100644
index 0000000..0e64669
--- /dev/null
+++ b/tests/Steergen.Cli.IntegrationTests/RulesPackCommandsTests.cs
@@ -0,0 +1,683 @@
+using Steergen.Cli.Commands;
+using Steergen.Core.Configuration;
+using Steergen.Core.Model;
+using Steergen.Core.Packs;
+
+namespace Steergen.Cli.IntegrationTests;
+
+[Collection("CliOutput")]
+///
+/// Integration tests for rules pack CLI commands:
+/// steergen rules-pack add, steergen rules-pack remove,
+/// steergen rules-pack list, steergen update --rules,
+/// and steergen run with rules packs configured.
+///
+public sealed class RulesPackCommandsTests
+{
+ private static string CreateTempDir()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "steergen-rp-test-" + Guid.NewGuid());
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ private static async Task WriteConfigAsync(
+ string dir,
+ string? projectRoot = null,
+ IReadOnlyList? rulesPacks = null,
+ IReadOnlyList? registeredTargets = null)
+ {
+ var path = Path.Combine(dir, "steergen.config.yaml");
+ var config = new SteeringConfiguration
+ {
+ ProjectRoot = projectRoot,
+ RulesPacks = rulesPacks ?? [],
+ RegisteredTargets = registeredTargets?.ToList() ?? [],
+ };
+ var writer = new SteergenConfigWriter();
+ await writer.WriteAsync(path, config);
+ return path;
+ }
+
+ private static string GetCacheBaseDirectory()
+ {
+ var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ return Path.Combine(userProfile, ".steergen");
+ }
+
+ ///
+ /// Creates a fake rules pack in the local cache directory for testing.
+ /// Returns the cache path where the pack was created.
+ ///
+ private static string CreateFakeRulesPackInCache(
+ string owner,
+ string repo,
+ string refValue,
+ PackScope scope,
+ string packName = "test-rules-pack",
+ string? rulesContent = null)
+ {
+ var cacheBase = GetCacheBaseDirectory();
+ var cachePath = Path.Combine(cacheBase, "rules", owner, repo, refValue);
+ Directory.CreateDirectory(cachePath);
+
+ // Write pack.yaml manifest
+ var manifest = $"""
+ name: "{packName}"
+ version: "1.0.0"
+ minSteergenVersion: "0.1.0"
+ scope: {scope.ToString().ToLowerInvariant()}
+ """;
+ File.WriteAllText(Path.Combine(cachePath, "pack.yaml"), manifest);
+
+ // Write a sample rules document
+ var rules = rulesContent ?? """
+ ---
+ id: test-rules-doc
+ version: "1.0.0"
+ title: Test Rules
+ scope: global
+ status: active
+ ---
+
+ # Test Rules
+
+ :::rule id="TEST-001" mandatory="true" category="testing"
+ All code must have tests. This is a test rule from a rules pack.
+ :::
+ """;
+ File.WriteAllText(Path.Combine(cachePath, "test-rules.md"), rules);
+
+ return cachePath;
+ }
+
+ ///
+ /// Removes a fake rules pack from the local cache directory.
+ ///
+ private static void RemoveFakeRulesPackFromCache(string owner, string repo, string refValue)
+ {
+ var cacheBase = GetCacheBaseDirectory();
+ var cachePath = Path.Combine(cacheBase, "rules", owner, repo, refValue);
+ if (Directory.Exists(cachePath))
+ Directory.Delete(cachePath, recursive: true);
+
+ // Clean up empty parent directories
+ var repoDir = Path.Combine(cacheBase, "rules", owner, repo);
+ if (Directory.Exists(repoDir) && !Directory.EnumerateFileSystemEntries(repoDir).Any())
+ Directory.Delete(repoDir);
+
+ var ownerDir = Path.Combine(cacheBase, "rules", owner);
+ if (Directory.Exists(ownerDir) && !Directory.EnumerateFileSystemEntries(ownerDir).Any())
+ Directory.Delete(ownerDir);
+ }
+
+ // ── rules-pack add ───────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task RulesPackAdd_InvalidSourceFormat_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = await WriteConfigAsync(dir);
+ var exitCode = await RulesPackAddCommand.ExecuteAsync(
+ configPath, "invalid-format", refValue: null, path: null, scopeStr: null);
+
+ Assert.Equal(2, exitCode);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task RulesPackAdd_InvalidScope_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = await WriteConfigAsync(dir);
+ var exitCode = await RulesPackAddCommand.ExecuteAsync(
+ configPath, "github:owner/repo", refValue: "v1.0.0", path: null, scopeStr: "invalid-scope");
+
+ Assert.Equal(2, exitCode);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task RulesPackAdd_ValidSource_PersistsToConfig()
+ {
+ // This test uses a fake cached pack to avoid network calls during add.
+ // The add command will attempt to download, which will fail for a non-existent repo.
+ // We test the config persistence path by pre-caching the pack.
+ var dir = CreateTempDir();
+ var owner = "test-rp-add-owner";
+ var repo = "test-rp-add-repo";
+ var refValue = "v1.0.0";
+
+ try
+ {
+ CreateFakeRulesPackInCache(owner, repo, refValue, PackScope.Global);
+ var configPath = await WriteConfigAsync(dir);
+
+ // The add command downloads first — since we pre-cached, it will still try to download
+ // from GitHub and fail. Instead, test the registration service directly.
+ var entry = new RulesPackEntry
+ {
+ Source = $"github:{owner}/{repo}",
+ Ref = refValue,
+ Scope = PackScope.Global,
+ };
+ var svc = new RulesPackRegistrationService();
+ var result = await svc.AddAsync(configPath, entry);
+
+ Assert.True(result.Success);
+
+ var loader = new SteergenConfigLoader();
+ var config = await loader.LoadAsync(configPath);
+ Assert.Single(config.RulesPacks);
+ Assert.Equal($"github:{owner}/{repo}", config.RulesPacks[0].Source);
+ Assert.Equal(refValue, config.RulesPacks[0].Ref);
+ Assert.Equal(PackScope.Global, config.RulesPacks[0].Scope);
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ RemoveFakeRulesPackFromCache(owner, repo, refValue);
+ }
+ }
+
+ [Fact]
+ public async Task RulesPackAdd_WithPathAndScope_PersistsAllFieldsToConfig()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = await WriteConfigAsync(dir);
+
+ var entry = new RulesPackEntry
+ {
+ Source = "github:acme/rules",
+ Ref = "abc123def456789012345678901234567890abcd",
+ Path = "backend-team",
+ Scope = PackScope.Supplemental,
+ };
+ var svc = new RulesPackRegistrationService();
+ var result = await svc.AddAsync(configPath, entry);
+
+ Assert.True(result.Success);
+
+ var loader = new SteergenConfigLoader();
+ var config = await loader.LoadAsync(configPath);
+ Assert.Single(config.RulesPacks);
+ Assert.Equal("github:acme/rules", config.RulesPacks[0].Source);
+ Assert.Equal("abc123def456789012345678901234567890abcd", config.RulesPacks[0].Ref);
+ Assert.Equal("backend-team", config.RulesPacks[0].Path);
+ Assert.Equal(PackScope.Supplemental, config.RulesPacks[0].Scope);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task RulesPackAdd_DuplicateSource_DoesNotDuplicate()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = await WriteConfigAsync(dir);
+
+ var entry = new RulesPackEntry
+ {
+ Source = "github:acme/rules",
+ Ref = "v1.0.0",
+ };
+ var svc = new RulesPackRegistrationService();
+ await svc.AddAsync(configPath, entry);
+ var secondResult = await svc.AddAsync(configPath, entry);
+
+ Assert.True(secondResult.Success);
+ Assert.True(secondResult.WasAlreadyPresent);
+
+ var loader = new SteergenConfigLoader();
+ var config = await loader.LoadAsync(configPath);
+ Assert.Single(config.RulesPacks);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task RulesPackAdd_MissingConfigFile_ReturnsFailure()
+ {
+ var svc = new RulesPackRegistrationService();
+ var entry = new RulesPackEntry { Source = "github:owner/repo" };
+ var result = await svc.AddAsync("/nonexistent/steergen.config.yaml", entry);
+
+ Assert.False(result.Success);
+ }
+
+ // ── rules-pack remove ────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task RulesPackRemove_ExistingEntry_RemovesFromConfig()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var rulesPacks = new List
+ {
+ new() { Source = "github:acme/baseline-rules", Ref = "v1.0.0", Scope = PackScope.Global },
+ new() { Source = "github:acme/team-rules", Ref = "v2.0.0", Scope = PackScope.Supplemental },
+ };
+ var configPath = await WriteConfigAsync(dir, rulesPacks: rulesPacks);
+
+ var exitCode = await RulesPackRemoveCommand.ExecuteAsync(
+ configPath, "github:acme/baseline-rules");
+
+ Assert.Equal(0, exitCode);
+
+ var loader = new SteergenConfigLoader();
+ var config = await loader.LoadAsync(configPath);
+ Assert.Single(config.RulesPacks);
+ Assert.Equal("github:acme/team-rules", config.RulesPacks[0].Source);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task RulesPackRemove_NotPresent_ReturnsExitCode0Idempotently()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = await WriteConfigAsync(dir);
+ var exitCode = await RulesPackRemoveCommand.ExecuteAsync(
+ configPath, "github:nonexistent/repo");
+
+ Assert.Equal(0, exitCode);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task RulesPackRemove_MissingConfigFile_ReturnsExitCode2()
+ {
+ var exitCode = await RulesPackRemoveCommand.ExecuteAsync(
+ "/nonexistent/steergen.config.yaml", "github:owner/repo");
+
+ Assert.Equal(2, exitCode);
+ }
+
+ [Fact]
+ public async Task RulesPackRemove_LeavesOtherEntriesIntact()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var rulesPacks = new List
+ {
+ new() { Source = "github:acme/security-rules", Ref = "v1.0.0", Scope = PackScope.Global },
+ new() { Source = "github:acme/team-rules", Ref = "v2.0.0", Scope = PackScope.Supplemental },
+ new() { Source = "github:acme/project-rules", Ref = "v3.0.0", Scope = PackScope.Project },
+ };
+ var configPath = await WriteConfigAsync(dir, rulesPacks: rulesPacks);
+
+ await RulesPackRemoveCommand.ExecuteAsync(configPath, "github:acme/team-rules");
+
+ var loader = new SteergenConfigLoader();
+ var config = await loader.LoadAsync(configPath);
+ Assert.Equal(2, config.RulesPacks.Count);
+ Assert.Contains(config.RulesPacks, r => r.Source == "github:acme/security-rules");
+ Assert.Contains(config.RulesPacks, r => r.Source == "github:acme/project-rules");
+ Assert.DoesNotContain(config.RulesPacks, r => r.Source == "github:acme/team-rules");
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ // ── rules-pack list ──────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task RulesPackList_NoPacksConfigured_ReturnsExitCode0()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = await WriteConfigAsync(dir);
+ var exitCode = await RulesPackListCommand.RunAsync(configPath);
+
+ Assert.Equal(0, exitCode);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task RulesPackList_WithConfiguredPacks_ReturnsExitCode0()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var rulesPacks = new List
+ {
+ new() { Source = "github:acme/baseline-rules", Ref = "v1.0.0", Scope = PackScope.Global },
+ new() { Source = "github:acme/team-rules", Ref = "v2.0.0", Scope = PackScope.Supplemental },
+ };
+ var configPath = await WriteConfigAsync(dir, rulesPacks: rulesPacks);
+
+ var exitCode = await RulesPackListCommand.RunAsync(configPath);
+
+ Assert.Equal(0, exitCode);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task RulesPackList_MissingConfigFile_ReturnsExitCode2()
+ {
+ var exitCode = await RulesPackListCommand.RunAsync("/nonexistent/steergen.config.yaml");
+ Assert.Equal(2, exitCode);
+ }
+
+ // ── update --rules ───────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task UpdateRules_NoRulesPacksConfigured_ReturnsExitCode0()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = await WriteConfigAsync(dir);
+ var result = await UpdateCommand.RunRulesUpdateAsync(configPath, force: false);
+ Assert.Equal(0, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task UpdateRules_MissingConfigFile_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = Path.Combine(dir, "does-not-exist.yaml");
+ var result = await UpdateCommand.RunRulesUpdateAsync(configPath, force: false);
+ Assert.Equal(2, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task UpdateRules_InvalidSourceFormat_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var rulesPacks = new List
+ {
+ new() { Source = "not-a-valid-source" },
+ };
+ var configPath = await WriteConfigAsync(dir, rulesPacks: rulesPacks);
+ var result = await UpdateCommand.RunRulesUpdateAsync(configPath, force: false);
+ Assert.Equal(2, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task UpdateRules_UnreachableGitHubSource_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var rulesPacks = new List
+ {
+ new() { Source = "github:nonexistent-xyz-owner/nonexistent-xyz-repo", Ref = "v1.0.0" },
+ };
+ var configPath = await WriteConfigAsync(dir, rulesPacks: rulesPacks);
+ var result = await UpdateCommand.RunRulesUpdateAsync(configPath, force: false);
+ Assert.Equal(2, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ // ── steergen run with rules packs ────────────────────────────────────────
+
+ [Fact]
+ public async Task Run_WithCachedRulesPack_MergesRulesIntoOutput()
+ {
+ var dir = CreateTempDir();
+ var owner = "test-rp-run-owner";
+ var repo = "test-rp-run-repo";
+ var refValue = "v1.0.0";
+
+ try
+ {
+ // Set up a fake rules pack in the cache with a known rule
+ var rulesContent = """
+ ---
+ id: pack-rules-doc
+ version: "1.0.0"
+ title: Pack Rules
+ scope: global
+ status: active
+ ---
+
+ # Pack Rules
+
+ :::rule id="PACK-001" mandatory="true" category="governance"
+ All services must implement health check endpoints. This rule comes from a rules pack.
+ :::
+ """;
+ CreateFakeRulesPackInCache(owner, repo, refValue, PackScope.Global, "integration-test-pack", rulesContent);
+
+ // Set up project with a local steering document
+ var projectDir = Path.Combine(dir, "steering", "project");
+ Directory.CreateDirectory(projectDir);
+ var projectDoc = """
+ ---
+ id: project-rules-doc
+ version: "1.0.0"
+ title: Project Rules
+ scope: project
+ status: active
+ ---
+
+ # Project Rules
+
+ :::rule id="PROJ-001" mandatory="true" category="testing"
+ All code must have unit tests covering critical paths.
+ :::
+ """;
+ File.WriteAllText(Path.Combine(projectDir, "project-rules.md"), projectDoc);
+
+ // Write config referencing the rules pack
+ var rulesPacks = new List
+ {
+ new() { Source = $"github:{owner}/{repo}", Ref = refValue, Scope = PackScope.Global },
+ };
+ var configPath = await WriteConfigAsync(dir, projectRoot: projectDir, rulesPacks: rulesPacks, registeredTargets: ["speckit"]);
+
+ var outputDir = Path.Combine(dir, "output");
+ Directory.CreateDirectory(outputDir);
+
+ var exitCode = await RunCommand.RunAsync(
+ configPath: configPath,
+ globalRoot: null,
+ projectRoot: null,
+ outputBase: outputDir,
+ explicitTargets: ["speckit"],
+ quiet: true,
+ cancellationToken: default);
+
+ Assert.Equal(0, exitCode);
+
+ // Verify output was generated (speckit target produces files)
+ var generatedFiles = Directory.GetFiles(outputDir, "*", SearchOption.AllDirectories);
+ Assert.True(generatedFiles.Length > 0, "Expected generated output files from speckit target");
+
+ // Verify that the rules pack rule (PACK-001) appears in the generated output
+ var allContent = string.Join("\n", generatedFiles.Select(File.ReadAllText));
+ Assert.Contains("PACK-001", allContent);
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ RemoveFakeRulesPackFromCache(owner, repo, refValue);
+ }
+ }
+
+ [Fact]
+ public async Task Run_WithRulesPackNotInCache_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var projectDir = Path.Combine(dir, "steering", "project");
+ Directory.CreateDirectory(projectDir);
+ var projectDoc = """
+ ---
+ id: project-doc
+ version: "1.0.0"
+ title: Project Rules
+ scope: project
+ status: active
+ ---
+
+ # Project Rules
+
+ :::rule id="PROJ-001" mandatory="true" category="testing"
+ All code must have tests.
+ :::
+ """;
+ File.WriteAllText(Path.Combine(projectDir, "project-rules.md"), projectDoc);
+
+ // Reference a rules pack that is NOT in the cache
+ var rulesPacks = new List
+ {
+ new() { Source = "github:nonexistent-cache-owner/nonexistent-cache-repo", Ref = "v9.9.9", Scope = PackScope.Global },
+ };
+ var configPath = await WriteConfigAsync(dir, projectRoot: projectDir, rulesPacks: rulesPacks, registeredTargets: ["speckit"]);
+
+ var outputDir = Path.Combine(dir, "output");
+ Directory.CreateDirectory(outputDir);
+
+ var exitCode = await RunCommand.RunAsync(
+ configPath: configPath,
+ globalRoot: null,
+ projectRoot: null,
+ outputBase: outputDir,
+ explicitTargets: ["speckit"],
+ quiet: true,
+ cancellationToken: default);
+
+ // RP005 error: pack not in cache → exit code 2
+ Assert.Equal(2, exitCode);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task Run_WithMultipleRulesPacks_MergesWithScopePrecedence()
+ {
+ var dir = CreateTempDir();
+ var globalOwner = "test-rp-global-owner";
+ var globalRepo = "test-rp-global-repo";
+ var globalRef = "v1.0.0";
+ var suppOwner = "test-rp-supp-owner";
+ var suppRepo = "test-rp-supp-repo";
+ var suppRef = "v2.0.0";
+
+ try
+ {
+ // Create a global-scoped rules pack
+ var globalRules = """
+ ---
+ id: global-pack-doc
+ version: "1.0.0"
+ title: Global Baseline Rules
+ scope: global
+ status: active
+ ---
+
+ # Global Baseline Rules
+
+ :::rule id="GLOBAL-001" mandatory="true" category="governance"
+ All services must have documentation.
+ :::
+ """;
+ CreateFakeRulesPackInCache(globalOwner, globalRepo, globalRef, PackScope.Global, "global-baseline", globalRules);
+
+ // Create a supplemental-scoped rules pack
+ var suppRules = """
+ ---
+ id: supp-pack-doc
+ version: "1.0.0"
+ title: Supplemental Team Rules
+ scope: supplemental
+ status: active
+ ---
+
+ # Supplemental Team Rules
+
+ :::rule id="SUPP-001" mandatory="true" category="quality"
+ Code coverage must exceed 80 percent for all services.
+ :::
+ """;
+ CreateFakeRulesPackInCache(suppOwner, suppRepo, suppRef, PackScope.Supplemental, "team-supplemental", suppRules);
+
+ // Set up project with a local steering document
+ var projectDir = Path.Combine(dir, "steering", "project");
+ Directory.CreateDirectory(projectDir);
+ var projectDoc = """
+ ---
+ id: local-project-doc
+ version: "1.0.0"
+ title: Local Project Rules
+ scope: project
+ status: active
+ ---
+
+ # Local Project Rules
+
+ :::rule id="LOCAL-001" mandatory="true" category="testing"
+ Integration tests must cover all API endpoints.
+ :::
+ """;
+ File.WriteAllText(Path.Combine(projectDir, "local-rules.md"), projectDoc);
+
+ // Write config referencing both rules packs
+ var rulesPacks = new List
+ {
+ new() { Source = $"github:{globalOwner}/{globalRepo}", Ref = globalRef, Scope = PackScope.Global },
+ new() { Source = $"github:{suppOwner}/{suppRepo}", Ref = suppRef, Scope = PackScope.Supplemental },
+ };
+ var configPath = await WriteConfigAsync(dir, projectRoot: projectDir, rulesPacks: rulesPacks, registeredTargets: ["speckit"]);
+
+ var outputDir = Path.Combine(dir, "output");
+ Directory.CreateDirectory(outputDir);
+
+ var exitCode = await RunCommand.RunAsync(
+ configPath: configPath,
+ globalRoot: null,
+ projectRoot: null,
+ outputBase: outputDir,
+ explicitTargets: ["speckit"],
+ quiet: true,
+ cancellationToken: default);
+
+ Assert.Equal(0, exitCode);
+
+ // Verify all rules from all sources appear in the output
+ var generatedFiles = Directory.GetFiles(outputDir, "*", SearchOption.AllDirectories);
+ Assert.True(generatedFiles.Length > 0, "Expected generated output files");
+
+ var allContent = string.Join("\n", generatedFiles.Select(File.ReadAllText));
+ Assert.Contains("GLOBAL-001", allContent);
+ Assert.Contains("SUPP-001", allContent);
+ Assert.Contains("LOCAL-001", allContent);
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ RemoveFakeRulesPackFromCache(globalOwner, globalRepo, globalRef);
+ RemoveFakeRulesPackFromCache(suppOwner, suppRepo, suppRef);
+ }
+ }
+}
diff --git a/tests/Steergen.Cli.IntegrationTests/RunAndTargetCommandsTests.cs b/tests/Steergen.Cli.IntegrationTests/RunAndTargetCommandsTests.cs
index 3367d64..d165839 100644
--- a/tests/Steergen.Cli.IntegrationTests/RunAndTargetCommandsTests.cs
+++ b/tests/Steergen.Cli.IntegrationTests/RunAndTargetCommandsTests.cs
@@ -1,6 +1,7 @@
using Steergen.Cli.Commands;
using Steergen.Core.Configuration;
using Steergen.Core.Model;
+using Steergen.Core.Targets;
using System.CommandLine;
using Xunit;
@@ -11,8 +12,24 @@ namespace Steergen.Cli.IntegrationTests;
/// Integration tests for the run command with explicit --target scoping,
/// and for target add/target remove commands.
///
-public sealed class RunAndTargetCommandsTests
+public sealed class RunAndTargetCommandsTests : IDisposable
{
+ public RunAndTargetCommandsTests()
+ {
+ TargetRegistry.Clear();
+ TargetRegistry.RegisterBuiltins(new StubTemplateProvider());
+ }
+
+ public void Dispose()
+ {
+ TargetRegistry.Clear();
+ }
+
+ private sealed class StubTemplateProvider : ITemplateProvider
+ {
+ public string GetTemplate(string targetId, string templateName) => string.Empty;
+ }
+
private static readonly string FixturesRoot =
Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
@@ -30,7 +47,6 @@ private static async Task WriteConfigAsync(
var writer = new SteergenConfigWriter();
var config = new SteeringConfiguration
{
- GlobalRoot = globalRoot,
ProjectRoot = projectRoot,
RegisteredTargets = (registeredTargets ?? []).ToList(),
};
diff --git a/tests/Steergen.Cli.IntegrationTests/RunCatchAllRoutingTests.cs b/tests/Steergen.Cli.IntegrationTests/RunCatchAllRoutingTests.cs
index 5dd1f70..77f85e6 100644
--- a/tests/Steergen.Cli.IntegrationTests/RunCatchAllRoutingTests.cs
+++ b/tests/Steergen.Cli.IntegrationTests/RunCatchAllRoutingTests.cs
@@ -115,7 +115,6 @@ public async Task Run_CatchAllFixture_NoCatchAllLayout_UnmatchedRulesFallBackToO
var configPath = Path.Combine(globalRoot, "steergen.config.yaml");
await new SteergenConfigWriter().WriteAsync(configPath, new SteeringConfiguration
{
- GlobalRoot = globalRoot,
Targets =
[
new TargetConfiguration
@@ -128,7 +127,7 @@ public async Task Run_CatchAllFixture_NoCatchAllLayout_UnmatchedRulesFallBackToO
],
});
- await RunCommand.RunAsync(configPath, null, null, outputDir, ["speckit"], quiet: true, cancellationToken: default);
+ await RunCommand.RunAsync(configPath, globalRoot, null, outputDir, ["speckit"], quiet: true, cancellationToken: default);
// With this layout, globalRoot is the destination root; stripping it leaves just "other.md"
var otherMdPath = Path.Combine(outputDir, "other.md");
@@ -234,7 +233,6 @@ public async Task Run_KiroWithLegacyConfiguredOutputPath_IgnoresTargetIdPrefixWh
var configPath = Path.Combine(workspace, "steergen.config.yaml");
await new Steergen.Core.Configuration.SteergenConfigWriter().WriteAsync(configPath, new Steergen.Core.Model.SteeringConfiguration
{
- GlobalRoot = globalRoot,
ProjectRoot = projectRoot,
RegisteredTargets = ["kiro"],
Targets =
@@ -252,7 +250,7 @@ public async Task Run_KiroWithLegacyConfiguredOutputPath_IgnoresTargetIdPrefixWh
var exitCode = await RunCommand.RunAsync(
configPath: configPath,
- globalRoot: null,
+ globalRoot: globalRoot,
projectRoot: null,
outputBase: null,
explicitTargets: [],
@@ -292,7 +290,6 @@ public async Task Run_WithConfiguredGenerationRoot_WritesRoutedFilesRelativeToGe
var configPath = Path.Combine(workspace, "steergen.config.yaml");
await new SteergenConfigWriter().WriteAsync(configPath, new SteeringConfiguration
{
- GlobalRoot = globalRoot,
ProjectRoot = projectRoot,
GenerationRoot = workspace,
RegisteredTargets = ["kiro"],
@@ -302,7 +299,7 @@ public async Task Run_WithConfiguredGenerationRoot_WritesRoutedFilesRelativeToGe
var exitCode = await RunCommand.RunAsync(
configPath: configPath,
- globalRoot: null,
+ globalRoot: globalRoot,
projectRoot: null,
outputBase: null,
explicitTargets: [],
diff --git a/tests/Steergen.Cli.IntegrationTests/RunLayoutConventionsAcceptanceTests.cs b/tests/Steergen.Cli.IntegrationTests/RunLayoutConventionsAcceptanceTests.cs
index f4e9803..8d065fb 100644
--- a/tests/Steergen.Cli.IntegrationTests/RunLayoutConventionsAcceptanceTests.cs
+++ b/tests/Steergen.Cli.IntegrationTests/RunLayoutConventionsAcceptanceTests.cs
@@ -71,7 +71,7 @@ await File.WriteAllTextAsync(Path.Combine(workspace, "mixed.md"),
var configPath = Path.Combine(workspace, "steergen.config.yaml");
await new SteergenConfigWriter().WriteAsync(configPath, new SteeringConfiguration
{
- GlobalRoot = workspace,
+
Targets =
[
new TargetConfiguration
@@ -83,7 +83,7 @@ await File.WriteAllTextAsync(Path.Combine(workspace, "mixed.md"),
],
});
- var exitCode = await RunCommand.RunAsync(configPath, null, null, outputDir, ["speckit"], quiet: true, cancellationToken: default);
+ var exitCode = await RunCommand.RunAsync(configPath, workspace, null, outputDir, ["speckit"], quiet: true, cancellationToken: default);
Assert.Equal(0, exitCode);
Assert.True(File.Exists(Path.Combine(outputDir, "override-output.md")),
@@ -112,7 +112,7 @@ await File.WriteAllTextAsync(Path.Combine(workspace, "mixed.md"),
var configPath = Path.Combine(workspace, "steergen.config.yaml");
await new SteergenConfigWriter().WriteAsync(configPath, new SteeringConfiguration
{
- GlobalRoot = workspace,
+
Targets =
[
new TargetConfiguration
@@ -124,7 +124,7 @@ await File.WriteAllTextAsync(Path.Combine(workspace, "mixed.md"),
],
});
- var exitCode = await RunCommand.RunAsync(configPath, null, null, outputDir, ["speckit"], quiet: true, cancellationToken: default);
+ var exitCode = await RunCommand.RunAsync(configPath, workspace, null, outputDir, ["speckit"], quiet: true, cancellationToken: default);
Assert.Equal(0, exitCode);
Assert.True(File.Exists(Path.Combine(outputDir, "override-output.md")),
@@ -153,7 +153,7 @@ await File.WriteAllTextAsync(Path.Combine(workspace, "mixed.md"),
var configPath = Path.Combine(workspace, "steergen.config.yaml");
await new SteergenConfigWriter().WriteAsync(configPath, new SteeringConfiguration
{
- GlobalRoot = workspace,
+
Targets =
[
new TargetConfiguration { Id = "speckit", Enabled = true, OutputPath = outputDir, LayoutOverridePath = speckitOverridePath },
@@ -161,7 +161,7 @@ await File.WriteAllTextAsync(Path.Combine(workspace, "mixed.md"),
],
});
- var exitCode = await RunCommand.RunAsync(configPath, null, null, outputDir, ["speckit", "kiro"], quiet: true, cancellationToken: default);
+ var exitCode = await RunCommand.RunAsync(configPath, workspace, null, outputDir, ["speckit", "kiro"], quiet: true, cancellationToken: default);
Assert.Equal(0, exitCode);
Assert.True(File.Exists(Path.Combine(outputDir, "override-output.md")),
diff --git a/tests/Steergen.Cli.IntegrationTests/RunLayoutOverrideTests.cs b/tests/Steergen.Cli.IntegrationTests/RunLayoutOverrideTests.cs
index 3738ebe..e69de29 100644
--- a/tests/Steergen.Cli.IntegrationTests/RunLayoutOverrideTests.cs
+++ b/tests/Steergen.Cli.IntegrationTests/RunLayoutOverrideTests.cs
@@ -1,278 +0,0 @@
-using Steergen.Cli.Commands;
-using Steergen.Core.Configuration;
-using Steergen.Core.Model;
-using Steergen.Templates;
-using Xunit;
-
-namespace Steergen.Cli.IntegrationTests;
-
-[Collection("CliOutput")]
-public sealed class RunLayoutOverrideTests
-{
- private static readonly string RoutingFixturesRoot =
- Path.GetFullPath(Path.Combine(
- AppContext.BaseDirectory,
- "..", "..", "..", "..", "..", "tests", "Fixtures", "RealisticGovernance", "RoutingLayouts"));
-
- private static string MakeTempDir() =>
- Directory.CreateTempSubdirectory("layout-override-test-").FullName;
-
- /// Routes everything to "custom-output.md" in the given directory.
- private static string SingleFileLayoutYaml(string dir)
- {
- var d = dir.Replace("\\", "/");
- return $"""
- version: "1.0"
- roots:
- globalRoot: "{d}"
- projectRoot: "{d}"
- targetRoot: "{d}"
- routes:
- - id: core-anchor
- scope: both
- explicit: true
- anchor: core
- order: 10
- match:
- category: core
- destination:
- directory: "{d}"
- fileName: "custom-output"
- extension: ".md"
- - id: catch-all
- scope: both
- explicit: false
- order: 99
- match:
- category: "*"
- destination:
- directory: "{d}"
- fileName: "custom-output"
- extension: ".md"
- fallback:
- mode: other-at-core-anchor
- fileBaseName: other
- purge:
- roots: []
- globs: []
- """;
- }
-
- [Fact]
- public async Task Run_OverrideOnSpeckit_SpeckitUsesCustomLayout()
- {
- var workspace = MakeTempDir();
- var outputDir = MakeTempDir();
- try
- {
- var overridePath = Path.Combine(workspace, "speckit-override.yaml");
- await File.WriteAllTextAsync(overridePath, SingleFileLayoutYaml(workspace));
- await File.WriteAllTextAsync(Path.Combine(workspace, "mixed.md"),
- await File.ReadAllTextAsync(Path.Combine(RoutingFixturesRoot, "mixed-domains-fixture.md")));
-
- var configPath = Path.Combine(workspace, "steergen.config.yaml");
- await new SteergenConfigWriter().WriteAsync(configPath, new SteeringConfiguration
- {
- GlobalRoot = workspace,
- Targets =
- [
- new TargetConfiguration
- {
- Id = "speckit", Enabled = true,
- OutputPath = outputDir,
- LayoutOverridePath = overridePath,
- },
- ],
- });
-
- var exitCode = await RunCommand.RunAsync(configPath, null, null, outputDir, ["speckit"], quiet: true, cancellationToken: default);
-
- Assert.Equal(0, exitCode);
- // Override routes to workspace/custom-output.md; stripping workspace prefix → custom-output.md under outputDir
- Assert.True(File.Exists(Path.Combine(outputDir, "custom-output.md")),
- "speckit should write to custom-output.md when override is active");
- Assert.False(File.Exists(Path.Combine(outputDir, "constitution.md")),
- "constitution.md should not exist when custom single-file override is active");
- }
- finally
- {
- if (Directory.Exists(workspace)) Directory.Delete(workspace, recursive: true);
- if (Directory.Exists(outputDir)) Directory.Delete(outputDir, recursive: true);
- }
- }
-
- [Fact]
- public async Task Run_OverrideOnSpeckit_KiroUsesDefaultLayout()
- {
- var workspace = MakeTempDir();
- var outputDir = MakeTempDir();
- try
- {
- var overridePath = Path.Combine(workspace, "speckit-override.yaml");
- await File.WriteAllTextAsync(overridePath, SingleFileLayoutYaml(workspace));
- await File.WriteAllTextAsync(Path.Combine(workspace, "mixed.md"),
- await File.ReadAllTextAsync(Path.Combine(RoutingFixturesRoot, "mixed-domains-fixture.md")));
-
- var configPath = Path.Combine(workspace, "steergen.config.yaml");
- await new SteergenConfigWriter().WriteAsync(configPath, new SteeringConfiguration
- {
- GlobalRoot = workspace,
- Targets =
- [
- new TargetConfiguration { Id = "speckit", Enabled = true, OutputPath = outputDir, LayoutOverridePath = overridePath },
- new TargetConfiguration { Id = "kiro", Enabled = true, OutputPath = outputDir, LayoutOverridePath = null },
- ],
- });
-
- var exitCode = await RunCommand.RunAsync(configPath, null, null, outputDir, ["speckit", "kiro"], quiet: true, cancellationToken: default);
-
- Assert.Equal(0, exitCode);
- Assert.True(File.Exists(Path.Combine(outputDir, "custom-output.md")),
- "speckit custom-output.md should exist with override");
-
- // kiro default layout → .kiro/steering/ under outputDir
- var kiroFiles = Directory.GetFiles(Path.Combine(outputDir, ".kiro", "steering"), "*.md");
- Assert.True(kiroFiles.Length > 0,
- "kiro should produce .md files using the default layout (not affected by speckit override)");
- Assert.False(File.Exists(Path.Combine(outputDir, ".kiro", "steering", "custom-output.md")),
- "kiro should not have custom-output.md");
- }
- finally
- {
- if (Directory.Exists(workspace)) Directory.Delete(workspace, recursive: true);
- if (Directory.Exists(outputDir)) Directory.Delete(outputDir, recursive: true);
- }
- }
-
- [Fact]
- public async Task Run_RelativeLayoutOverridePath_ResolvedRelativeToConfigDirectory()
- {
- var workspace = MakeTempDir();
- var subDir = Path.Combine(workspace, "layouts");
- Directory.CreateDirectory(subDir);
- var outputDir = MakeTempDir();
- try
- {
- await File.WriteAllTextAsync(Path.Combine(subDir, "my-override.yaml"), SingleFileLayoutYaml(workspace));
- await File.WriteAllTextAsync(Path.Combine(workspace, "mixed.md"),
- await File.ReadAllTextAsync(Path.Combine(RoutingFixturesRoot, "mixed-domains-fixture.md")));
-
- var configPath = Path.Combine(workspace, "steergen.config.yaml");
- await new SteergenConfigWriter().WriteAsync(configPath, new SteeringConfiguration
- {
- GlobalRoot = workspace,
- Targets =
- [
- new TargetConfiguration
- {
- Id = "speckit", Enabled = true,
- OutputPath = outputDir,
- LayoutOverridePath = "layouts/my-override.yaml",
- },
- ],
- });
-
- var exitCode = await RunCommand.RunAsync(configPath, null, null, outputDir, ["speckit"], quiet: true, cancellationToken: default);
-
- Assert.Equal(0, exitCode);
- Assert.True(File.Exists(Path.Combine(outputDir, "custom-output.md")),
- "Relative layoutOverridePath should be resolved from the config file directory");
- }
- finally
- {
- if (Directory.Exists(workspace)) Directory.Delete(workspace, recursive: true);
- if (Directory.Exists(outputDir)) Directory.Delete(outputDir, recursive: true);
- }
- }
-
- [Fact]
- public async Task Run_WithOverride_RouteResolutionsReportMergedProvenance()
- {
- var workspace = MakeTempDir();
- var outputDir = MakeTempDir();
- try
- {
- var overridePath = Path.Combine(workspace, "speckit-override.yaml");
- await File.WriteAllTextAsync(overridePath, SingleFileLayoutYaml(workspace));
- await File.WriteAllTextAsync(Path.Combine(workspace, "mixed.md"),
- await File.ReadAllTextAsync(Path.Combine(RoutingFixturesRoot, "mixed-domains-fixture.md")));
-
- var targetConfig = new Core.Model.TargetConfiguration
- {
- Id = "speckit", Enabled = true,
- OutputPath = outputDir,
- LayoutOverridePath = overridePath,
- };
-
- var result = await new Core.Generation.GenerationPipeline().RunAsync(
- globalDocuments: LoadDocuments(workspace),
- projectDocuments: [],
- activeProfiles: [],
- targets: [new Core.Targets.Speckit.SpeckitTargetComponent(new EmbeddedTemplateProvider())],
- targetConfigs: [targetConfig],
- cancellationToken: default);
-
- Assert.NotNull(result.RouteResolutions);
- var speckitResolutions = result.RouteResolutions["speckit"];
- var resolvedResults = speckitResolutions.Where(r => r.IsResolved).ToList();
- Assert.True(resolvedResults.Count > 0);
- Assert.All(resolvedResults, r =>
- Assert.True(r.Source == Core.Model.RouteProvenance.Merged,
- $"Rule '{r.RuleId}' should have Source=Merged when override is active, got {r.Source}"));
- }
- finally
- {
- if (Directory.Exists(workspace)) Directory.Delete(workspace, recursive: true);
- if (Directory.Exists(outputDir)) Directory.Delete(outputDir, recursive: true);
- }
- }
-
- [Fact]
- public async Task Run_WithoutOverride_RouteResolutionsReportDefaultProvenance()
- {
- var workspace = MakeTempDir();
- var outputDir = MakeTempDir();
- try
- {
- await File.WriteAllTextAsync(Path.Combine(workspace, "mixed.md"),
- await File.ReadAllTextAsync(Path.Combine(RoutingFixturesRoot, "mixed-domains-fixture.md")));
-
- var result = await new Core.Generation.GenerationPipeline().RunAsync(
- globalDocuments: LoadDocuments(workspace),
- projectDocuments: [],
- activeProfiles: [],
- targets: [new Core.Targets.Speckit.SpeckitTargetComponent(new EmbeddedTemplateProvider())],
- targetConfigs:
- [
- new Core.Model.TargetConfiguration
- {
- Id = "speckit", Enabled = true,
- OutputPath = outputDir,
- LayoutOverridePath = null,
- }
- ],
- cancellationToken: default);
-
- var resolvedResults = result.RouteResolutions!["speckit"].Where(r => r.IsResolved).ToList();
- Assert.True(resolvedResults.Count > 0);
- Assert.All(resolvedResults, r =>
- Assert.True(r.Source == Core.Model.RouteProvenance.Default,
- $"Rule '{r.RuleId}' should have Source=Default when no override, got {r.Source}"));
- }
- finally
- {
- if (Directory.Exists(workspace)) Directory.Delete(workspace, recursive: true);
- if (Directory.Exists(outputDir)) Directory.Delete(outputDir, recursive: true);
- }
- }
-
- private static IReadOnlyList LoadDocuments(string? root)
- {
- if (root is null || !Directory.Exists(root)) return [];
- return Directory
- .EnumerateFiles(root, "*.md", SearchOption.AllDirectories)
- .OrderBy(p => p, StringComparer.Ordinal)
- .Select(path => Core.Parsing.SteeringMarkdownParser.Parse(File.ReadAllText(path), path))
- .ToList();
- }
-}
diff --git a/tests/Steergen.Cli.IntegrationTests/Security/PackSecurityIntegrationTests.cs b/tests/Steergen.Cli.IntegrationTests/Security/PackSecurityIntegrationTests.cs
new file mode 100644
index 0000000..6e11855
--- /dev/null
+++ b/tests/Steergen.Cli.IntegrationTests/Security/PackSecurityIntegrationTests.cs
@@ -0,0 +1,379 @@
+using System.Formats.Tar;
+using System.IO.Compression;
+using System.Net;
+using Steergen.Core.Packs;
+using Steergen.Core.Targets;
+
+namespace Steergen.Cli.IntegrationTests.Security;
+
+///
+/// Security integration tests for pack infrastructure.
+/// Validates that path traversal in archives is rejected, template files exceeding
+/// 1 MB are rejected, and symbolic links in pack directories are not followed.
+///
+/// Requirements: 14.2, 14.3, 14.4, 14.5
+///
+[Collection("CliOutput")]
+public sealed class PackSecurityIntegrationTests : IDisposable
+{
+ private readonly string _cacheBase;
+
+ public PackSecurityIntegrationTests()
+ {
+ _cacheBase = Path.Combine(Path.GetTempPath(), $"PackSecurityTests_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_cacheBase);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_cacheBase))
+ Directory.Delete(_cacheBase, recursive: true);
+ }
+
+ // ── Path traversal in archive entries ─────────────────────────────────────
+
+ ///
+ /// Requirement 14.3: Archives containing path traversal sequences (../) are rejected.
+ ///
+ [Theory]
+ [InlineData("../../../etc/passwd")]
+ [InlineData("templates/../../../etc/shadow")]
+ [InlineData("..\\..\\Windows\\System32\\config\\SAM")]
+ public async Task DownloadAsync_ArchiveWithPathTraversal_IsRejected(string maliciousPath)
+ {
+ var archive = CreateArchiveWithTraversalEntry("repo-v1.0.0", maliciousPath);
+ var httpClient = CreateHttpClient(HttpStatusCode.OK, archive);
+ var downloader = new PackDownloader(httpClient, _cacheBase);
+
+ var source = new GitHubPackSource { Owner = "evil", Repo = "pack", Ref = "v1.0.0" };
+ var result = await downloader.DownloadAsync(source, PackType.Template, force: false);
+
+ Assert.False(result.Success);
+ Assert.NotEmpty(result.Diagnostics);
+ Assert.Contains(result.Diagnostics, d =>
+ d.Code is "DL003" or "DL004");
+ }
+
+ ///
+ /// Requirement 14.4: Archives with entries that resolve outside the pack directory are rejected.
+ ///
+ [Fact]
+ public async Task DownloadAsync_ArchiveWithAbsolutePath_IsRejected()
+ {
+ var archive = CreateArchiveWithTraversalEntry("repo-v1.0.0", "/etc/passwd");
+ var httpClient = CreateHttpClient(HttpStatusCode.OK, archive);
+ var downloader = new PackDownloader(httpClient, _cacheBase);
+
+ var source = new GitHubPackSource { Owner = "evil", Repo = "pack", Ref = "v1.0.0" };
+ var result = await downloader.DownloadAsync(source, PackType.Template, force: false);
+
+ Assert.False(result.Success);
+ Assert.NotEmpty(result.Diagnostics);
+ Assert.Contains(result.Diagnostics, d =>
+ d.Code is "DL003" or "DL004");
+ }
+
+ ///
+ /// Requirement 14.3: The static IsPathSafe method rejects traversal sequences.
+ ///
+ [Theory]
+ [InlineData("../secret.txt")]
+ [InlineData("foo/../../bar")]
+ [InlineData("..\\..\\windows\\system32")]
+ [InlineData("/absolute/path")]
+ [InlineData("\\absolute\\path")]
+ public void IsPathSafe_TraversalPaths_ReturnsFalse(string path)
+ {
+ Assert.False(PackDownloader.IsPathSafe(path));
+ }
+
+ ///
+ /// Requirement 14.3: Safe paths within the pack directory are accepted.
+ ///
+ [Theory]
+ [InlineData("pack.yaml")]
+ [InlineData("kiro/main.scriban")]
+ [InlineData("templates/speckit/rules.scriban")]
+ public void IsPathSafe_ValidPaths_ReturnsTrue(string path)
+ {
+ Assert.True(PackDownloader.IsPathSafe(path));
+ }
+
+ // ── Template files > 1 MB are rejected ────────────────────────────────────
+
+ ///
+ /// Requirement 14.2: Template files exceeding 1 MB (1,048,576 bytes) are rejected
+ /// by the TemplateResolver with a TP002 diagnostic.
+ ///
+ [Fact]
+ public void TemplateResolver_FileExceeding1MB_ThrowsTemplatePackException()
+ {
+ var packDir = Path.Combine(_cacheBase, "oversized-pack");
+ var targetDir = Path.Combine(packDir, "kiro");
+ Directory.CreateDirectory(targetDir);
+
+ // Create a template file that exceeds 1 MB
+ var oversizedContent = new string('X', 1_048_577); // 1 MB + 1 byte
+ File.WriteAllText(Path.Combine(targetDir, "main.scriban"), oversizedContent);
+
+ var embeddedProvider = new StubTemplateProvider();
+ var resolver = new TemplateResolver(
+ localOverridePath: packDir,
+ cachedPackPath: null,
+ embeddedProvider: embeddedProvider);
+
+ var ex = Assert.Throws(() =>
+ resolver.GetTemplate("kiro", "main"));
+
+ Assert.Equal("TP002", ex.Diagnostic.Code);
+ Assert.Contains("1048576", ex.Diagnostic.Message);
+ }
+
+ ///
+ /// Requirement 14.2: Template files at exactly 1 MB are accepted.
+ ///
+ [Fact]
+ public void TemplateResolver_FileAtExactly1MB_IsAccepted()
+ {
+ var packDir = Path.Combine(_cacheBase, "exact-1mb-pack");
+ var targetDir = Path.Combine(packDir, "kiro");
+ Directory.CreateDirectory(targetDir);
+
+ // Create a template file at exactly 1 MB
+ var content = new string('Y', 1_048_576); // Exactly 1 MB
+ File.WriteAllText(Path.Combine(targetDir, "main.scriban"), content);
+
+ var embeddedProvider = new StubTemplateProvider();
+ var resolver = new TemplateResolver(
+ localOverridePath: packDir,
+ cachedPackPath: null,
+ embeddedProvider: embeddedProvider);
+
+ var result = resolver.GetTemplate("kiro", "main");
+
+ Assert.Equal(content, result);
+ }
+
+ ///
+ /// Requirement 14.7: Rules pack loader rejects individual files > 1 MB.
+ /// Verified via the RulesPackLoader which checks file size before parsing.
+ ///
+ [Fact]
+ public void RulesPackLoader_FileExceeding1MB_EmitsRP004Diagnostic()
+ {
+ // Set up a fake rules pack cache directory with an oversized file
+ var packCacheDir = Path.Combine(_cacheBase, "rules", "acme", "big-rules", "v1.0.0");
+ Directory.CreateDirectory(packCacheDir);
+
+ // Write a valid pack.yaml
+ File.WriteAllText(Path.Combine(packCacheDir, "pack.yaml"),
+ "name: big-rules\nversion: 1.0.0\nminSteergenVersion: 0.1.0\nscope: global\n");
+
+ // Write an oversized .md file (> 1 MB)
+ var oversizedContent = "---\nid: oversized-doc\n---\n" + new string('Z', 1_048_577);
+ File.WriteAllText(Path.Combine(packCacheDir, "oversized.md"), oversizedContent);
+
+ var manifestParser = new PackManifestParser();
+ var validator = new Core.Validation.SteeringValidator();
+ var loader = new RulesPackLoader(manifestParser, validator);
+
+ var configs = new List
+ {
+ new()
+ {
+ Source = new GitHubPackSource { Owner = "acme", Repo = "big-rules", Ref = "v1.0.0" }
+ }
+ };
+
+ var result = loader.Load(configs, _cacheBase, "99.0.0");
+
+ Assert.Contains(result.Diagnostics, d => d.Code == "RP004");
+ Assert.Contains(result.Diagnostics, d => d.Message.Contains("1 MB"));
+ }
+
+ // ── Symlinks in pack directories are not followed ─────────────────────────
+
+ ///
+ /// Requirement 14.5: Symbolic links in template pack directories are not followed.
+ /// The TemplateResolver skips files that are symbolic links (ReparsePoint attribute).
+ /// Note: Symlink creation may require elevated privileges on Windows.
+ ///
+ [Fact]
+ public void TemplateResolver_SymlinkInPackDirectory_IsNotFollowed()
+ {
+ var packDir = Path.Combine(_cacheBase, "symlink-pack");
+ var targetDir = Path.Combine(packDir, "kiro");
+ Directory.CreateDirectory(targetDir);
+
+ // Create a real file outside the pack directory that the symlink will point to
+ var secretFile = Path.Combine(_cacheBase, "secret.txt");
+ File.WriteAllText(secretFile, "SECRET CONTENT SHOULD NOT BE ACCESSIBLE");
+
+ var symlinkPath = Path.Combine(targetDir, "main.scriban");
+
+ // Attempt to create a symbolic link — skip test if insufficient privileges
+ try
+ {
+ File.CreateSymbolicLink(symlinkPath, secretFile);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Symlink creation requires elevated privileges on Windows
+ return; // Skip test gracefully
+ }
+ catch (IOException)
+ {
+ // May also fail with IOException on some systems
+ return; // Skip test gracefully
+ }
+
+ // Verify the symlink was actually created
+ if (!File.Exists(symlinkPath))
+ return; // Skip if symlink creation silently failed
+
+ var embeddedProvider = new StubTemplateProvider("FALLBACK CONTENT");
+ var resolver = new TemplateResolver(
+ localOverridePath: packDir,
+ cachedPackPath: null,
+ embeddedProvider: embeddedProvider);
+
+ // The resolver should NOT follow the symlink and should fall back to embedded
+ var result = resolver.GetTemplate("kiro", "main");
+
+ Assert.Equal("FALLBACK CONTENT", result);
+ Assert.NotEqual("SECRET CONTENT SHOULD NOT BE ACCESSIBLE", result);
+ }
+
+ ///
+ /// Requirement 14.8: Symbolic links in rules pack directories are not followed.
+ /// The RulesPackLoader skips symlinked files and directories during enumeration.
+ /// Note: Symlink creation may require elevated privileges on Windows.
+ ///
+ [Fact]
+ public void RulesPackLoader_SymlinkInPackDirectory_IsNotFollowed()
+ {
+ var packCacheDir = Path.Combine(_cacheBase, "rules", "acme", "symlink-rules", "v1.0.0");
+ Directory.CreateDirectory(packCacheDir);
+
+ // Write a valid pack.yaml
+ File.WriteAllText(Path.Combine(packCacheDir, "pack.yaml"),
+ "name: symlink-rules\nversion: 1.0.0\nminSteergenVersion: 0.1.0\nscope: global\n");
+
+ // Create a real .md file outside the pack directory
+ var externalDir = Path.Combine(_cacheBase, "external-rules");
+ Directory.CreateDirectory(externalDir);
+ File.WriteAllText(Path.Combine(externalDir, "secret-rule.md"),
+ "---\nid: secret-doc\n---\n:::rule id=\"SECRET-001\" severity=\"error\" domain=\"core\"\nSecret rule.\n:::");
+
+ // Attempt to create a directory symlink pointing to the external directory
+ var symlinkDir = Path.Combine(packCacheDir, "linked-rules");
+ try
+ {
+ Directory.CreateSymbolicLink(symlinkDir, externalDir);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return; // Skip test gracefully
+ }
+ catch (IOException)
+ {
+ return; // Skip test gracefully
+ }
+
+ if (!Directory.Exists(symlinkDir))
+ return; // Skip if symlink creation silently failed
+
+ var manifestParser = new PackManifestParser();
+ var validator = new Core.Validation.SteeringValidator();
+ var loader = new RulesPackLoader(manifestParser, validator);
+
+ var configs = new List
+ {
+ new()
+ {
+ Source = new GitHubPackSource { Owner = "acme", Repo = "symlink-rules", Ref = "v1.0.0" }
+ }
+ };
+
+ var result = loader.Load(configs, _cacheBase, "99.0.0");
+
+ // The loader should NOT have followed the symlink, so no documents from the external dir
+ Assert.DoesNotContain(result.Documents, d =>
+ d.Rules.Any(r => r.Id == "SECRET-001"));
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ ///
+ /// Creates a tar.gz archive containing a path traversal entry.
+ ///
+ private static byte[] CreateArchiveWithTraversalEntry(string topLevelDir, string maliciousPath)
+ {
+ using var memoryStream = new MemoryStream();
+ using (var gzipStream = new GZipStream(memoryStream, CompressionLevel.Fastest, leaveOpen: true))
+ using (var tarWriter = new TarWriter(gzipStream, leaveOpen: true))
+ {
+ var prefix = topLevelDir.TrimEnd('/') + "/";
+
+ // Write a valid pack.yaml first
+ var packYamlEntry = new PaxTarEntry(TarEntryType.RegularFile, prefix + "pack.yaml")
+ {
+ DataStream = new MemoryStream(
+ System.Text.Encoding.UTF8.GetBytes("name: evil-pack\nversion: 1.0.0\nminSteergenVersion: 1.0.0\n"))
+ };
+ tarWriter.WriteEntry(packYamlEntry);
+
+ // Write the malicious path traversal entry
+ var maliciousEntry = new PaxTarEntry(TarEntryType.RegularFile, prefix + maliciousPath)
+ {
+ DataStream = new MemoryStream(
+ System.Text.Encoding.UTF8.GetBytes("MALICIOUS CONTENT"))
+ };
+ tarWriter.WriteEntry(maliciousEntry);
+ }
+
+ return memoryStream.ToArray();
+ }
+
+ private static HttpClient CreateHttpClient(HttpStatusCode statusCode, byte[]? content = null)
+ {
+ var handler = new FakeHttpMessageHandler(statusCode, content);
+ return new HttpClient(handler);
+ }
+
+ private sealed class FakeHttpMessageHandler : HttpMessageHandler
+ {
+ private readonly HttpStatusCode _statusCode;
+ private readonly byte[]? _content;
+
+ public FakeHttpMessageHandler(HttpStatusCode statusCode, byte[]? content = null)
+ {
+ _statusCode = statusCode;
+ _content = content;
+ }
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ var response = new HttpResponseMessage(_statusCode);
+ if (_content is not null)
+ {
+ response.Content = new ByteArrayContent(_content);
+ }
+ return Task.FromResult(response);
+ }
+ }
+
+ private sealed class StubTemplateProvider : ITemplateProvider
+ {
+ private readonly string _fallbackContent;
+
+ public StubTemplateProvider(string fallbackContent = "")
+ {
+ _fallbackContent = fallbackContent;
+ }
+
+ public string GetTemplate(string targetId, string templateName) => _fallbackContent;
+ }
+}
diff --git a/tests/Steergen.Cli.IntegrationTests/TemplatePackCommandTests.cs b/tests/Steergen.Cli.IntegrationTests/TemplatePackCommandTests.cs
new file mode 100644
index 0000000..5d9c3f9
--- /dev/null
+++ b/tests/Steergen.Cli.IntegrationTests/TemplatePackCommandTests.cs
@@ -0,0 +1,598 @@
+using Steergen.Cli.Commands;
+using Steergen.Core.Configuration;
+using Steergen.Core.Model;
+using Steergen.Core.Targets;
+
+namespace Steergen.Cli.IntegrationTests;
+
+[Collection("CliOutput")]
+///
+/// Integration tests for template pack CLI commands:
+/// - steergen template-pack add / steergen template-pack remove
+/// - steergen update --templates
+/// - steergen run with template pack producing overridden output
+/// - steergen validate with malformed template pack reporting errors
+///
+/// Validates: Requirements 7.1, 7.4, 7.5, 6.1
+///
+public sealed class TemplatePackCommandTests : IDisposable
+{
+ private static readonly string FixturesRoot =
+ Path.GetFullPath(Path.Combine(
+ AppContext.BaseDirectory,
+ "..", "..", "..", "..", "..", "tests", "Fixtures", "RealisticGovernance"));
+
+ public TemplatePackCommandTests()
+ {
+ TargetRegistry.Clear();
+ TargetRegistry.RegisterBuiltins(new StubTemplateProvider());
+ }
+
+ public void Dispose()
+ {
+ TargetRegistry.Clear();
+ }
+
+ private sealed class StubTemplateProvider : ITemplateProvider
+ {
+ public string GetTemplate(string targetId, string templateName) => string.Empty;
+ }
+
+ private static string CreateTempDir()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), $"tpack-integ-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ private static async Task WriteConfigAsync(
+ string dir,
+ SteeringConfiguration? config = null)
+ {
+ var path = Path.Combine(dir, "steergen.config.yaml");
+ var writer = new SteergenConfigWriter();
+ config ??= new SteeringConfiguration
+ {
+ ProjectRoot = Path.Combine(FixturesRoot, "project"),
+ };
+ await writer.WriteAsync(path, config);
+ return path;
+ }
+
+ // ── template-pack add: local path ────────────────────────────────────────
+
+ [Fact]
+ public async Task TemplatePackAdd_LocalPath_ReturnsExitCode0()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var packDir = Path.Combine(dir, "templates");
+ Directory.CreateDirectory(packDir);
+
+ var configPath = await WriteConfigAsync(dir);
+ var result = await TemplatePackAddCommand.RunAsync(
+ configPath, source: null, refValue: null, localPath: packDir);
+
+ Assert.Equal(0, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task TemplatePackAdd_LocalPath_PersistsToConfig()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var packDir = Path.Combine(dir, "templates");
+ Directory.CreateDirectory(packDir);
+
+ var configPath = await WriteConfigAsync(dir);
+ await TemplatePackAddCommand.RunAsync(
+ configPath, source: null, refValue: null, localPath: packDir);
+
+ var loader = new SteergenConfigLoader();
+ var loaded = await loader.LoadAsync(configPath);
+
+ Assert.NotNull(loaded.TemplatePack);
+ Assert.Equal(packDir, loaded.TemplatePack.LocalPath);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task TemplatePackAdd_GitHubSource_PersistsSourceAndRefToConfig()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = await WriteConfigAsync(dir);
+
+ // This will fail to download (non-existent repo), but we can test
+ // that the source format validation works. Use a real-looking source.
+ var result = await TemplatePackAddCommand.RunAsync(
+ configPath,
+ source: "github:nonexistent-owner-xyz/nonexistent-repo-xyz",
+ refValue: "v1.0.0",
+ localPath: null);
+
+ // Download will fail for non-existent repo, so exit code is 2
+ Assert.Equal(2, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task TemplatePackAdd_InvalidSourceFormat_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = await WriteConfigAsync(dir);
+ var result = await TemplatePackAddCommand.RunAsync(
+ configPath, source: "invalid-format", refValue: null, localPath: null);
+
+ Assert.Equal(2, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task TemplatePackAdd_NeitherSourceNorPath_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = await WriteConfigAsync(dir);
+ var result = await TemplatePackAddCommand.RunAsync(
+ configPath, source: null, refValue: null, localPath: null);
+
+ Assert.Equal(2, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task TemplatePackAdd_BothSourceAndPath_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = await WriteConfigAsync(dir);
+ var result = await TemplatePackAddCommand.RunAsync(
+ configPath,
+ source: "github:owner/repo",
+ refValue: null,
+ localPath: "/some/path");
+
+ Assert.Equal(2, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task TemplatePackAdd_MissingConfigFile_ReturnsExitCode2()
+ {
+ var result = await TemplatePackAddCommand.RunAsync(
+ "/nonexistent/steergen.config.yaml",
+ source: null, refValue: null, localPath: "/some/path");
+
+ Assert.Equal(2, result);
+ }
+
+ // ── template-pack remove ─────────────────────────────────────────────────
+
+ [Fact]
+ public async Task TemplatePackRemove_WhenConfigured_ReturnsExitCode0()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var packDir = Path.Combine(dir, "templates");
+ Directory.CreateDirectory(packDir);
+
+ var config = new SteeringConfiguration
+ {
+ ProjectRoot = Path.Combine(FixturesRoot, "project"),
+ TemplatePack = new TemplatePackConfig { LocalPath = packDir },
+ };
+ var configPath = await WriteConfigAsync(dir, config);
+
+ var result = await TemplatePackRemoveCommand.RunAsync(configPath);
+
+ Assert.Equal(0, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task TemplatePackRemove_WhenConfigured_RemovesFromConfig()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var packDir = Path.Combine(dir, "templates");
+ Directory.CreateDirectory(packDir);
+
+ var config = new SteeringConfiguration
+ {
+ ProjectRoot = Path.Combine(FixturesRoot, "project"),
+ TemplatePack = new TemplatePackConfig { LocalPath = packDir },
+ };
+ var configPath = await WriteConfigAsync(dir, config);
+
+ await TemplatePackRemoveCommand.RunAsync(configPath);
+
+ var loader = new SteergenConfigLoader();
+ var loaded = await loader.LoadAsync(configPath);
+
+ Assert.Null(loaded.TemplatePack);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task TemplatePackRemove_WhenNotConfigured_ReturnsExitCode0()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = await WriteConfigAsync(dir);
+ var result = await TemplatePackRemoveCommand.RunAsync(configPath);
+
+ Assert.Equal(0, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task TemplatePackRemove_MissingConfigFile_ReturnsExitCode2()
+ {
+ var result = await TemplatePackRemoveCommand.RunAsync("/nonexistent/steergen.config.yaml");
+ Assert.Equal(2, result);
+ }
+
+ // ── update --templates ───────────────────────────────────────────────────
+
+ [Fact]
+ public async Task UpdateTemplates_NoTemplatePackConfigured_ReturnsExitCode0()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = await WriteConfigAsync(dir);
+ var result = await UpdateCommand.RunTemplatesUpdateAsync(configPath, force: false);
+
+ Assert.Equal(0, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task UpdateTemplates_MissingConfigFile_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = Path.Combine(dir, "does-not-exist.yaml");
+ var result = await UpdateCommand.RunTemplatesUpdateAsync(configPath, force: false);
+
+ Assert.Equal(2, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task UpdateTemplates_InvalidSourceFormat_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var config = new SteeringConfiguration
+ {
+ ProjectRoot = Path.Combine(FixturesRoot, "project"),
+ TemplatePack = new TemplatePackConfig { Source = "invalid-format" },
+ };
+ var configPath = await WriteConfigAsync(dir, config);
+ var result = await UpdateCommand.RunTemplatesUpdateAsync(configPath, force: false);
+
+ Assert.Equal(2, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task UpdateTemplates_UnreachableGitHubSource_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var config = new SteeringConfiguration
+ {
+ ProjectRoot = Path.Combine(FixturesRoot, "project"),
+ TemplatePack = new TemplatePackConfig
+ {
+ Source = "github:nonexistent-owner-xyz/nonexistent-repo-xyz",
+ Ref = "v1.0.0",
+ },
+ };
+ var configPath = await WriteConfigAsync(dir, config);
+ var result = await UpdateCommand.RunTemplatesUpdateAsync(configPath, force: false);
+
+ Assert.Equal(2, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ // ── run with template pack (local path) ──────────────────────────────────
+
+ [Fact]
+ public async Task Run_WithLocalTemplatePack_ProducesOverriddenOutput()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ // Create a local template pack with a custom kiro document template
+ var packDir = Path.Combine(dir, "templates");
+ Directory.CreateDirectory(Path.Combine(packDir, "kiro"));
+
+ // Write a custom template that produces distinctive output
+ await File.WriteAllTextAsync(
+ Path.Combine(packDir, "kiro", "document.scriban"),
+ "# CUSTOM TEMPLATE OUTPUT\n{{ for rule in rules }}{{ rule.id }}\n{{ end }}");
+
+ // Write pack.yaml manifest
+ await File.WriteAllTextAsync(
+ Path.Combine(packDir, "pack.yaml"),
+ """
+ name: "test-templates"
+ version: "1.0.0"
+ minSteergenVersion: "0.1.0"
+ targets:
+ - kiro
+ """);
+
+ var outputDir = Path.Combine(dir, "output");
+ Directory.CreateDirectory(outputDir);
+
+ var config = new SteeringConfiguration
+ {
+ ProjectRoot = Path.Combine(FixturesRoot, "project"),
+ RegisteredTargets = ["kiro"],
+ TemplatePack = new TemplatePackConfig { LocalPath = packDir },
+ };
+ var configPath = await WriteConfigAsync(dir, config);
+
+ var exitCode = await RunCommand.RunAsync(
+ configPath: configPath,
+ globalRoot: null,
+ projectRoot: null,
+ outputBase: outputDir,
+ explicitTargets: [],
+ quiet: true,
+ cancellationToken: default);
+
+ Assert.Equal(0, exitCode);
+
+ // Verify that output files exist and contain the custom template marker
+ var outputFiles = Directory.GetFiles(outputDir, "*", SearchOption.AllDirectories);
+ Assert.NotEmpty(outputFiles);
+
+ var anyContainsCustomMarker = outputFiles
+ .Select(f => File.ReadAllText(f))
+ .Any(content => content.Contains("CUSTOM TEMPLATE OUTPUT"));
+
+ Assert.True(anyContainsCustomMarker,
+ "At least one output file should contain the custom template marker 'CUSTOM TEMPLATE OUTPUT'");
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task Run_WithLocalTemplatePack_NonexistentPath_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var outputDir = Path.Combine(dir, "output");
+ Directory.CreateDirectory(outputDir);
+
+ var config = new SteeringConfiguration
+ {
+ ProjectRoot = Path.Combine(FixturesRoot, "project"),
+ RegisteredTargets = ["kiro"],
+ TemplatePack = new TemplatePackConfig
+ {
+ LocalPath = Path.Combine(dir, "nonexistent-pack-dir"),
+ },
+ };
+ var configPath = await WriteConfigAsync(dir, config);
+
+ var exitCode = await RunCommand.RunAsync(
+ configPath: configPath,
+ globalRoot: null,
+ projectRoot: null,
+ outputBase: outputDir,
+ explicitTargets: [],
+ quiet: true,
+ cancellationToken: default);
+
+ Assert.Equal(2, exitCode);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task Run_WithGitHubPackNotCached_ReturnsExitCode2WithTP007()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var outputDir = Path.Combine(dir, "output");
+ Directory.CreateDirectory(outputDir);
+
+ var config = new SteeringConfiguration
+ {
+ ProjectRoot = Path.Combine(FixturesRoot, "project"),
+ RegisteredTargets = ["kiro"],
+ TemplatePack = new TemplatePackConfig
+ {
+ Source = "github:some-owner/some-repo",
+ Ref = "abc123def456789012345678901234567890abcd",
+ },
+ };
+ var configPath = await WriteConfigAsync(dir, config);
+
+ var exitCode = await RunCommand.RunAsync(
+ configPath: configPath,
+ globalRoot: null,
+ projectRoot: null,
+ outputBase: outputDir,
+ explicitTargets: [],
+ quiet: true,
+ cancellationToken: default);
+
+ // TP007: configured GitHub pack not in local cache
+ Assert.Equal(2, exitCode);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ // ── validate with template pack ──────────────────────────────────────────
+
+ [Fact]
+ public async Task Validate_TemplatePackWithSyntaxErrors_ReturnsExitCode1()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var packDir = Path.Combine(dir, "templates");
+ Directory.CreateDirectory(Path.Combine(packDir, "kiro"));
+
+ // Write an invalid Scriban template (unclosed if block)
+ await File.WriteAllTextAsync(
+ Path.Combine(packDir, "kiro", "document.scriban"),
+ "{{ if true }}content without end");
+
+ var config = new SteeringConfiguration
+ {
+ RegisteredTargets = ["kiro"],
+ TemplatePack = new TemplatePackConfig { LocalPath = packDir },
+ };
+ var configPath = await WriteConfigAsync(dir, config);
+
+ var result = await ValidateCommand.RunAsync(
+ globalRoot: null,
+ projectRoot: null,
+ quiet: true,
+ configPath: configPath);
+
+ Assert.Equal(1, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task Validate_TemplatePackWithValidTemplates_ReturnsExitCode0()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var packDir = Path.Combine(dir, "templates");
+ Directory.CreateDirectory(Path.Combine(packDir, "kiro"));
+
+ // Write a valid Scriban template
+ await File.WriteAllTextAsync(
+ Path.Combine(packDir, "kiro", "document.scriban"),
+ "{{ for rule in rules }}{{ rule.id }}\n{{ end }}");
+
+ var config = new SteeringConfiguration
+ {
+ RegisteredTargets = ["kiro"],
+ TemplatePack = new TemplatePackConfig { LocalPath = packDir },
+ };
+ var configPath = await WriteConfigAsync(dir, config);
+
+ var result = await ValidateCommand.RunAsync(
+ globalRoot: null,
+ projectRoot: null,
+ quiet: true,
+ configPath: configPath);
+
+ Assert.Equal(0, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task Validate_TemplatePackWithMultipleSyntaxErrors_ReportsAllErrors()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var packDir = Path.Combine(dir, "templates");
+ Directory.CreateDirectory(Path.Combine(packDir, "kiro"));
+ Directory.CreateDirectory(Path.Combine(packDir, "speckit"));
+
+ // Write invalid templates for multiple targets
+ await File.WriteAllTextAsync(
+ Path.Combine(packDir, "kiro", "document.scriban"),
+ "{{ if true }}unclosed kiro");
+
+ await File.WriteAllTextAsync(
+ Path.Combine(packDir, "speckit", "document.scriban"),
+ "{{ for x in }}missing collection");
+
+ var config = new SteeringConfiguration
+ {
+ RegisteredTargets = ["kiro", "speckit"],
+ TemplatePack = new TemplatePackConfig { LocalPath = packDir },
+ };
+ var configPath = await WriteConfigAsync(dir, config);
+
+ var result = await ValidateCommand.RunAsync(
+ globalRoot: null,
+ projectRoot: null,
+ quiet: false,
+ configPath: configPath);
+
+ // Should report errors (exit code 1)
+ Assert.Equal(1, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task Validate_TemplatePackForUnregisteredTarget_ProducesWarningNotError()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var packDir = Path.Combine(dir, "templates");
+ Directory.CreateDirectory(Path.Combine(packDir, "unknown-target"));
+
+ // Write a valid template for an unregistered target
+ await File.WriteAllTextAsync(
+ Path.Combine(packDir, "unknown-target", "document.scriban"),
+ "plain text content");
+
+ var config = new SteeringConfiguration
+ {
+ RegisteredTargets = ["kiro"],
+ TemplatePack = new TemplatePackConfig { LocalPath = packDir },
+ };
+ var configPath = await WriteConfigAsync(dir, config);
+
+ // Warnings do not cause exit code 1
+ var result = await ValidateCommand.RunAsync(
+ globalRoot: null,
+ projectRoot: null,
+ quiet: false,
+ configPath: configPath);
+
+ Assert.Equal(0, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+}
diff --git a/tests/Steergen.Cli.IntegrationTests/UpdateCommandTests.cs b/tests/Steergen.Cli.IntegrationTests/UpdateCommandTests.cs
index 1e8f790..ddb02d4 100644
--- a/tests/Steergen.Cli.IntegrationTests/UpdateCommandTests.cs
+++ b/tests/Steergen.Cli.IntegrationTests/UpdateCommandTests.cs
@@ -1,6 +1,7 @@
using Steergen.Cli.Commands;
using Steergen.Core.Configuration;
using Steergen.Core.Model;
+using Steergen.Core.Packs;
using Xunit;
namespace Steergen.Cli.IntegrationTests;
@@ -28,7 +29,6 @@ private static async Task WriteConfigAsync(string dir, string? templateP
var path = Path.Combine(dir, "steergen.config.yaml");
var config = new SteeringConfiguration
{
- GlobalRoot = Path.Combine(dir, "steering", "global"),
ProjectRoot = Path.Combine(dir, "steering", "project"),
TemplatePackVersion = templatePackVersion,
};
@@ -37,6 +37,21 @@ private static async Task WriteConfigAsync(string dir, string? templateP
return path;
}
+ private static async Task WriteConfigWithRulesPacksAsync(
+ string dir,
+ IReadOnlyList rulesPacks)
+ {
+ var path = Path.Combine(dir, "steergen.config.yaml");
+ var config = new SteeringConfiguration
+ {
+ ProjectRoot = Path.Combine(dir, "steering", "project"),
+ RulesPacks = rulesPacks,
+ };
+ var writer = new SteergenConfigWriter();
+ await writer.WriteAsync(path, config);
+ return path;
+ }
+
private static async Task ReadTemplatePackVersionAsync(string configPath)
{
var loader = new SteergenConfigLoader();
@@ -206,4 +221,67 @@ public async Task Update_CommandAutoDiscoversConfigFromCurrentDirectory()
}
finally { Directory.Delete(dir, recursive: true); }
}
+
+ // ── --rules flag flows ────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task UpdateRules_NoRulesPacksConfigured_ReturnsExitCode0()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = await WriteConfigWithRulesPacksAsync(dir, []);
+ var result = await UpdateCommand.RunRulesUpdateAsync(configPath, force: false);
+ Assert.Equal(0, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task UpdateRules_MissingConfigFile_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var configPath = Path.Combine(dir, "does-not-exist.yaml");
+ var result = await UpdateCommand.RunRulesUpdateAsync(configPath, force: false);
+ Assert.Equal(2, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task UpdateRules_InvalidSourceFormat_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var rulesPacks = new List
+ {
+ new() { Source = "invalid-format" },
+ };
+ var configPath = await WriteConfigWithRulesPacksAsync(dir, rulesPacks);
+ var result = await UpdateCommand.RunRulesUpdateAsync(configPath, force: false);
+ Assert.Equal(2, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public async Task UpdateRules_UnreachableGitHubSource_ReturnsExitCode2()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ // This source points to a non-existent repo, so download will fail
+ var rulesPacks = new List
+ {
+ new() { Source = "github:nonexistent-owner-xyz/nonexistent-repo-xyz", Ref = "v1.0.0" },
+ };
+ var configPath = await WriteConfigWithRulesPacksAsync(dir, rulesPacks);
+ var result = await UpdateCommand.RunRulesUpdateAsync(configPath, force: false);
+ Assert.Equal(2, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
}
diff --git a/tests/Steergen.Cli.IntegrationTests/ValidateCommandTests.cs b/tests/Steergen.Cli.IntegrationTests/ValidateCommandTests.cs
index e80fe18..f24598f 100644
--- a/tests/Steergen.Cli.IntegrationTests/ValidateCommandTests.cs
+++ b/tests/Steergen.Cli.IntegrationTests/ValidateCommandTests.cs
@@ -224,8 +224,153 @@ await writer.WriteAsync(
Path.Combine(dir, "steergen.config.yaml"),
new SteeringConfiguration
{
- GlobalRoot = globalRoot,
ProjectRoot = projectRoot,
});
}
+
+ // ── Template pack validation: valid templates produce exit code 0 ──────
+
+ [Fact]
+ public async Task Validate_ValidTemplatePack_ReturnsExitCode0()
+ {
+ var dir = CreateTempDir();
+ var packDir = Path.Combine(dir, "templates");
+ Directory.CreateDirectory(Path.Combine(packDir, "kiro"));
+ try
+ {
+ // Write a valid Scriban template
+ await File.WriteAllTextAsync(
+ Path.Combine(packDir, "kiro", "document.scriban"),
+ "{{ rules }}");
+
+ // Write config pointing to the local template pack
+ var writer = new SteergenConfigWriter();
+ await writer.WriteAsync(
+ Path.Combine(dir, "steergen.config.yaml"),
+ new SteeringConfiguration
+ {
+ RegisteredTargets = ["kiro"],
+ TemplatePack = new TemplatePackConfig { LocalPath = packDir },
+ });
+
+ var result = await ValidateCommand.RunAsync(
+ globalRoot: null,
+ projectRoot: null,
+ quiet: true,
+ configPath: Path.Combine(dir, "steergen.config.yaml"));
+
+ Assert.Equal(0, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ // ── Template pack validation: syntax errors produce exit code 1 ────────
+
+ [Fact]
+ public async Task Validate_TemplatePackWithSyntaxErrors_ReturnsExitCode1()
+ {
+ var dir = CreateTempDir();
+ var packDir = Path.Combine(dir, "templates");
+ Directory.CreateDirectory(Path.Combine(packDir, "kiro"));
+ try
+ {
+ // Write an invalid Scriban template (unclosed if block)
+ await File.WriteAllTextAsync(
+ Path.Combine(packDir, "kiro", "document.scriban"),
+ "{{ if true }}content without end");
+
+ // Write config pointing to the local template pack
+ var writer = new SteergenConfigWriter();
+ await writer.WriteAsync(
+ Path.Combine(dir, "steergen.config.yaml"),
+ new SteeringConfiguration
+ {
+ RegisteredTargets = ["kiro"],
+ TemplatePack = new TemplatePackConfig { LocalPath = packDir },
+ });
+
+ var result = await ValidateCommand.RunAsync(
+ globalRoot: null,
+ projectRoot: null,
+ quiet: true,
+ configPath: Path.Combine(dir, "steergen.config.yaml"));
+
+ Assert.Equal(1, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ // ── Template pack validation: unknown template name produces warning ───
+
+ [Fact]
+ public async Task Validate_TemplatePackWithUnknownTemplateName_ProducesWarningNotError()
+ {
+ var dir = CreateTempDir();
+ var packDir = Path.Combine(dir, "templates");
+ Directory.CreateDirectory(Path.Combine(packDir, "kiro"));
+ try
+ {
+ // Write a valid template with an unknown name for the kiro target
+ await File.WriteAllTextAsync(
+ Path.Combine(packDir, "kiro", "unknown-template.scriban"),
+ "plain text content");
+
+ // Write config pointing to the local template pack
+ var writer = new SteergenConfigWriter();
+ await writer.WriteAsync(
+ Path.Combine(dir, "steergen.config.yaml"),
+ new SteeringConfiguration
+ {
+ RegisteredTargets = ["kiro"],
+ TemplatePack = new TemplatePackConfig { LocalPath = packDir },
+ });
+
+ // Warnings do not cause exit code 1
+ var result = await ValidateCommand.RunAsync(
+ globalRoot: null,
+ projectRoot: null,
+ quiet: false,
+ configPath: Path.Combine(dir, "steergen.config.yaml"));
+
+ Assert.Equal(0, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ // ── Template pack validation: unregistered target produces warning ─────
+
+ [Fact]
+ public async Task Validate_TemplatePackWithUnregisteredTarget_ProducesWarning()
+ {
+ var dir = CreateTempDir();
+ var packDir = Path.Combine(dir, "templates");
+ Directory.CreateDirectory(Path.Combine(packDir, "unknown-target"));
+ try
+ {
+ // Write a valid template for an unregistered target
+ await File.WriteAllTextAsync(
+ Path.Combine(packDir, "unknown-target", "document.scriban"),
+ "plain text content");
+
+ // Write config with only "kiro" registered (not "unknown-target")
+ var writer = new SteergenConfigWriter();
+ await writer.WriteAsync(
+ Path.Combine(dir, "steergen.config.yaml"),
+ new SteeringConfiguration
+ {
+ RegisteredTargets = ["kiro"],
+ TemplatePack = new TemplatePackConfig { LocalPath = packDir },
+ });
+
+ // Warnings do not cause exit code 1
+ var result = await ValidateCommand.RunAsync(
+ globalRoot: null,
+ projectRoot: null,
+ quiet: false,
+ configPath: Path.Combine(dir, "steergen.config.yaml"));
+
+ Assert.Equal(0, result);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
}
diff --git a/tests/Steergen.Core.PropertyTests/Merge/OverlayAndProfileProperties.cs b/tests/Steergen.Core.PropertyTests/Merge/OverlayAndProfileProperties.cs
index f132ad2..36081a1 100644
--- a/tests/Steergen.Core.PropertyTests/Merge/OverlayAndProfileProperties.cs
+++ b/tests/Steergen.Core.PropertyTests/Merge/OverlayAndProfileProperties.cs
@@ -52,7 +52,7 @@ public void Resolve_AllRulesReturned_WhenNoProfileFiltering()
var r3 = MakeRule("R003");
var global = new[] { MakeDoc("doc-A", r1, r2, r3) };
- var result = resolver.Resolve(global, [], []);
+ var result = resolver.Resolve(global, (IEnumerable)Array.Empty(), Array.Empty());
var ruleIds = result.Rules.Select(r => r.Id).ToHashSet();
Assert.Contains("R001", ruleIds);
@@ -70,7 +70,7 @@ public void Resolve_RuleOrder_IsStableSortedByDocIdThenRuleId()
MakeDoc("doc-A", MakeRule("R004"), MakeRule("R003")),
};
- var result = resolver.Resolve(global, [], []);
+ var result = resolver.Resolve(global, (IEnumerable)Array.Empty(), Array.Empty());
var ids = result.Rules.Select(r => r.Id).ToList();
var sorted = ids.OrderBy(x => x, StringComparer.Ordinal).ToList();
Assert.Equal(sorted, ids);
diff --git a/tests/Steergen.Core.PropertyTests/Packs/CachePathProperties.cs b/tests/Steergen.Core.PropertyTests/Packs/CachePathProperties.cs
new file mode 100644
index 0000000..7fa5a8e
--- /dev/null
+++ b/tests/Steergen.Core.PropertyTests/Packs/CachePathProperties.cs
@@ -0,0 +1,180 @@
+using CsCheck;
+using Steergen.Core.Packs;
+
+namespace Steergen.Core.PropertyTests.Packs;
+
+///
+/// Property tests for cache path construction.
+/// Property 5: Cache Path Construction
+/// Validates: Requirements 4.1, 12.1
+///
+public sealed class CachePathProperties
+{
+ // ── Generators ───────────────────────────────────────────────────────────────
+
+ ///
+ /// Generates alphanumeric owner/repo/ref segments of reasonable length.
+ /// Avoids empty strings and path-separator characters.
+ ///
+ private static readonly Gen GenSegment =
+ Gen.String[Gen.Char.AlphaNumeric, 1, 20];
+
+ private static readonly Gen GenPackType =
+ Gen.OneOf(Gen.Const(PackType.Template), Gen.Const(PackType.Rules));
+
+ // ── Property 5: Cache Path Construction ──────────────────────────────────────
+ //
+ // For any valid (owner, repo, ref) tuple and pack type, the computed cache path
+ // SHALL equal {userProfileDirectory}/.steergen/{packTypeDir}/{owner}/{repo}/{ref}/
+ // where packTypeDir is "packs" for template packs and "rules" for rules packs.
+
+ [Fact]
+ public void GetCachedPath_MatchesExpectedFormat_ForAllPackTypes()
+ {
+ // **Validates: Requirements 4.1, 12.1**
+ var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ var cacheBase = Path.Combine(userProfile, ".steergen");
+ var downloader = new PackDownloader(new HttpClient(), cacheBase);
+
+ Gen.Select(GenSegment, GenSegment, GenSegment, GenPackType)
+ .Sample(
+ (owner, repo, refValue, packType) =>
+ {
+ var source = new GitHubPackSource
+ {
+ Owner = owner,
+ Repo = repo,
+ Ref = refValue
+ };
+
+ var result = downloader.GetCachedPath(source, packType);
+
+ var packTypeDir = packType == PackType.Template ? "packs" : "rules";
+ var expected = Path.Combine(
+ cacheBase,
+ packTypeDir,
+ owner,
+ repo,
+ refValue) + Path.DirectorySeparatorChar;
+
+ Assert.Equal(expected, result);
+ },
+ iter: 200,
+ print: t => $"owner={t.Item1}, repo={t.Item2}, ref={t.Item3}, packType={t.Item4}");
+ }
+
+ [Fact]
+ public void GetCachedPath_UsesPacksDir_ForTemplatePacks()
+ {
+ // **Validates: Requirements 4.1**
+ var cacheBase = Path.Combine("C:", "users", "test", ".steergen");
+ var downloader = new PackDownloader(new HttpClient(), cacheBase);
+
+ Gen.Select(GenSegment, GenSegment, GenSegment)
+ .Sample(
+ (owner, repo, refValue) =>
+ {
+ var source = new GitHubPackSource
+ {
+ Owner = owner,
+ Repo = repo,
+ Ref = refValue
+ };
+
+ var result = downloader.GetCachedPath(source, PackType.Template);
+
+ Assert.Contains(Path.Combine("packs", owner, repo, refValue), result);
+ Assert.DoesNotContain("rules", result);
+ },
+ iter: 100,
+ print: t => $"owner={t.Item1}, repo={t.Item2}, ref={t.Item3}");
+ }
+
+ [Fact]
+ public void GetCachedPath_UsesRulesDir_ForRulesPacks()
+ {
+ // **Validates: Requirements 12.1**
+ var cacheBase = Path.Combine("C:", "users", "test", ".steergen");
+ var downloader = new PackDownloader(new HttpClient(), cacheBase);
+
+ Gen.Select(GenSegment, GenSegment, GenSegment)
+ .Sample(
+ (owner, repo, refValue) =>
+ {
+ var source = new GitHubPackSource
+ {
+ Owner = owner,
+ Repo = repo,
+ Ref = refValue
+ };
+
+ var result = downloader.GetCachedPath(source, PackType.Rules);
+
+ Assert.Contains(Path.Combine("rules", owner, repo, refValue), result);
+ Assert.DoesNotContain("packs", result);
+ },
+ iter: 100,
+ print: t => $"owner={t.Item1}, repo={t.Item2}, ref={t.Item3}");
+ }
+
+ [Fact]
+ public void GetCachedPath_EndsWithDirectorySeparator()
+ {
+ // **Validates: Requirements 4.1, 12.1**
+ var cacheBase = Path.Combine("home", "user", ".steergen");
+ var downloader = new PackDownloader(new HttpClient(), cacheBase);
+
+ Gen.Select(GenSegment, GenSegment, GenSegment, GenPackType)
+ .Sample(
+ (owner, repo, refValue, packType) =>
+ {
+ var source = new GitHubPackSource
+ {
+ Owner = owner,
+ Repo = repo,
+ Ref = refValue
+ };
+
+ var result = downloader.GetCachedPath(source, packType);
+
+ Assert.EndsWith(Path.DirectorySeparatorChar.ToString(), result);
+ },
+ iter: 100,
+ print: t => $"owner={t.Item1}, repo={t.Item2}, ref={t.Item3}, packType={t.Item4}");
+ }
+
+ [Fact]
+ public void GetCachedPath_UsesHEAD_WhenRefIsNull()
+ {
+ // **Validates: Requirements 4.1, 12.1**
+ // When no ref is specified, the cache path uses "HEAD" as the ref directory.
+ var cacheBase = Path.Combine("home", "user", ".steergen");
+ var downloader = new PackDownloader(new HttpClient(), cacheBase);
+
+ Gen.Select(GenSegment, GenSegment, GenPackType)
+ .Sample(
+ (owner, repo, packType) =>
+ {
+ var source = new GitHubPackSource
+ {
+ Owner = owner,
+ Repo = repo,
+ Ref = null
+ };
+
+ var result = downloader.GetCachedPath(source, packType);
+
+ var packTypeDir = packType == PackType.Template ? "packs" : "rules";
+ var expected = Path.Combine(
+ cacheBase,
+ packTypeDir,
+ owner,
+ repo,
+ "HEAD") + Path.DirectorySeparatorChar;
+
+ Assert.Equal(expected, result);
+ },
+ iter: 100,
+ print: t => $"owner={t.Item1}, repo={t.Item2}, packType={t.Item3}");
+ }
+}
diff --git a/tests/Steergen.Core.PropertyTests/Packs/ConfigurationProperties.cs b/tests/Steergen.Core.PropertyTests/Packs/ConfigurationProperties.cs
new file mode 100644
index 0000000..5f267f5
--- /dev/null
+++ b/tests/Steergen.Core.PropertyTests/Packs/ConfigurationProperties.cs
@@ -0,0 +1,223 @@
+using CsCheck;
+using Steergen.Core.Configuration;
+using Steergen.Core.Model;
+using Steergen.Core.Packs;
+
+namespace Steergen.Core.PropertyTests.Packs;
+
+///
+/// Property tests for configuration round-trip serialization.
+///
+/// Property 8: Configuration Round-Trip
+/// For any valid SteeringConfiguration containing template pack and rules pack entries,
+/// serializing to YAML and deserializing back SHALL produce an equivalent configuration
+/// (all fields preserved including source, ref, path, and scope for each pack entry).
+///
+/// **Validates: Requirements 3.1, 10.1, 10.2**
+///
+public sealed class ConfigurationProperties : IDisposable
+{
+ private readonly string _testDir;
+
+ public ConfigurationProperties()
+ {
+ _testDir = Path.Combine(Path.GetTempPath(), "ConfigProps_" + Guid.NewGuid().ToString("N")[..8]);
+ Directory.CreateDirectory(_testDir);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_testDir))
+ Directory.Delete(_testDir, recursive: true);
+ }
+
+ // ── Generators ───────────────────────────────────────────────────────────
+
+ private static readonly Gen GenAlphaString =
+ Gen.String[Gen.Char['a', 'z'], 2, 10];
+
+ private static readonly Gen GenOwnerRepo =
+ Gen.Select(GenAlphaString, GenAlphaString)
+ .Select((owner, repo) => $"github:{owner}/{repo}");
+
+ private static readonly Gen GenRef =
+ Gen.OneOf(
+ Gen.String[Gen.Char['a', 'f'], 40, 40], // SHA-like
+ GenAlphaString.Select(s => $"v1.{s.Length}.0"), // tag-like
+ GenAlphaString); // branch-like
+
+ private static readonly Gen GenScope =
+ Gen.OneOf(Gen.Const(PackScope.Global), Gen.Const(PackScope.Supplemental), Gen.Const(PackScope.Project));
+
+ private static readonly Gen GenTemplatePackConfig =
+ Gen.Select(GenOwnerRepo, GenRef)
+ .Select((source, refVal) => new TemplatePackConfig
+ {
+ Source = source,
+ Ref = refVal,
+ LocalPath = null
+ });
+
+ private static readonly Gen GenRulesPackEntry =
+ Gen.Select(GenOwnerRepo, GenRef, Gen.Bool, GenAlphaString, GenScope)
+ .Select((source, refVal, hasPath, path, scope) => new RulesPackEntry
+ {
+ Source = source,
+ Ref = refVal,
+ Path = hasPath ? path : null,
+ Scope = scope
+ });
+
+ private static readonly Gen GenConfiguration =
+ Gen.Select(
+ GenAlphaString, // projectRoot
+ GenAlphaString, // generationRoot
+ GenAlphaString.Array[0, 3], // activeProfiles
+ GenAlphaString.Array[0, 3], // registeredTargets
+ Gen.Bool, // hasTemplatePack
+ GenTemplatePackConfig,
+ GenRulesPackEntry.Array[0, 3])
+ .Select((projectRoot, genRoot, profiles, targets, hasTp, tp, rulesPacks) =>
+ new SteeringConfiguration
+ {
+ ProjectRoot = projectRoot,
+ GenerationRoot = genRoot,
+ ActiveProfiles = profiles.ToList(),
+ RegisteredTargets = targets.ToList(),
+ TemplatePack = hasTp ? tp : null,
+ RulesPacks = rulesPacks.ToList()
+ });
+
+ // ── Property 8: Configuration Round-Trip ─────────────────────────────────
+
+ [Fact]
+ public void Configuration_RoundTrip_PreservesAllFields()
+ {
+ // **Validates: Requirements 3.1, 10.1, 10.2**
+ //
+ // For any valid SteeringConfiguration with template pack and rules pack entries,
+ // serializing to YAML and deserializing back produces an equivalent configuration.
+ var writer = new SteergenConfigWriter();
+ var loader = new SteergenConfigLoader();
+
+ GenConfiguration.Sample(
+ config =>
+ {
+ var filePath = Path.Combine(_testDir, $"config_{Guid.NewGuid():N}.yaml");
+
+ // Serialize
+ writer.WriteAsync(filePath, config).GetAwaiter().GetResult();
+
+ // Deserialize
+ var loaded = loader.LoadAsync(filePath).GetAwaiter().GetResult();
+
+ // Assert equivalence of core fields
+ Assert.Equal(config.ProjectRoot, loaded.ProjectRoot);
+ Assert.Equal(config.GenerationRoot, loaded.GenerationRoot);
+ Assert.Equal(config.ActiveProfiles, loaded.ActiveProfiles);
+ Assert.Equal(config.RegisteredTargets, loaded.RegisteredTargets);
+
+ // Assert template pack round-trip
+ if (config.TemplatePack is null)
+ {
+ Assert.Null(loaded.TemplatePack);
+ }
+ else
+ {
+ Assert.NotNull(loaded.TemplatePack);
+ Assert.Equal(config.TemplatePack.Source, loaded.TemplatePack.Source);
+ Assert.Equal(config.TemplatePack.Ref, loaded.TemplatePack.Ref);
+ Assert.Equal(config.TemplatePack.LocalPath, loaded.TemplatePack.LocalPath);
+ }
+
+ // Assert rules packs round-trip
+ Assert.Equal(config.RulesPacks.Count, loaded.RulesPacks.Count);
+ for (var i = 0; i < config.RulesPacks.Count; i++)
+ {
+ Assert.Equal(config.RulesPacks[i].Source, loaded.RulesPacks[i].Source);
+ Assert.Equal(config.RulesPacks[i].Ref, loaded.RulesPacks[i].Ref);
+ Assert.Equal(config.RulesPacks[i].Path, loaded.RulesPacks[i].Path);
+ Assert.Equal(config.RulesPacks[i].Scope, loaded.RulesPacks[i].Scope);
+ }
+
+ // Cleanup
+ File.Delete(filePath);
+ },
+ iter: 100,
+ print: config => $"projectRoot={config.ProjectRoot}, tp={config.TemplatePack is not null}, rulesPacks={config.RulesPacks.Count}");
+ }
+
+ [Fact]
+ public void Configuration_RoundTrip_PreservesRulesPackSourceRefPath()
+ {
+ // **Validates: Requirements 10.1, 10.2**
+ //
+ // Specifically validates that source, ref, and path fields on rules pack entries
+ // survive the round-trip through YAML serialization.
+ var writer = new SteergenConfigWriter();
+ var loader = new SteergenConfigLoader();
+
+ GenRulesPackEntry.Array[1, 5].Sample(
+ entries =>
+ {
+ var config = new SteeringConfiguration
+ {
+ ProjectRoot = "test",
+ RulesPacks = entries.ToList()
+ };
+
+ var filePath = Path.Combine(_testDir, $"rules_rt_{Guid.NewGuid():N}.yaml");
+
+ writer.WriteAsync(filePath, config).GetAwaiter().GetResult();
+ var loaded = loader.LoadAsync(filePath).GetAwaiter().GetResult();
+
+ Assert.Equal(entries.Length, loaded.RulesPacks.Count);
+ for (var i = 0; i < entries.Length; i++)
+ {
+ Assert.Equal(entries[i].Source, loaded.RulesPacks[i].Source);
+ Assert.Equal(entries[i].Ref, loaded.RulesPacks[i].Ref);
+ Assert.Equal(entries[i].Path, loaded.RulesPacks[i].Path);
+ Assert.Equal(entries[i].Scope, loaded.RulesPacks[i].Scope);
+ }
+
+ File.Delete(filePath);
+ },
+ iter: 100,
+ print: entries => $"count={entries.Length}");
+ }
+
+ [Fact]
+ public void Configuration_RoundTrip_PreservesTemplatePackFields()
+ {
+ // **Validates: Requirements 3.1**
+ //
+ // Specifically validates that template pack source and ref fields
+ // survive the round-trip through YAML serialization.
+ var writer = new SteergenConfigWriter();
+ var loader = new SteergenConfigLoader();
+
+ GenTemplatePackConfig.Sample(
+ tp =>
+ {
+ var config = new SteeringConfiguration
+ {
+ ProjectRoot = "test",
+ TemplatePack = tp
+ };
+
+ var filePath = Path.Combine(_testDir, $"tp_rt_{Guid.NewGuid():N}.yaml");
+
+ writer.WriteAsync(filePath, config).GetAwaiter().GetResult();
+ var loaded = loader.LoadAsync(filePath).GetAwaiter().GetResult();
+
+ Assert.NotNull(loaded.TemplatePack);
+ Assert.Equal(tp.Source, loaded.TemplatePack.Source);
+ Assert.Equal(tp.Ref, loaded.TemplatePack.Ref);
+ Assert.Equal(tp.LocalPath, loaded.TemplatePack.LocalPath);
+
+ File.Delete(filePath);
+ },
+ iter: 100,
+ print: tp => $"source={tp.Source}, ref={tp.Ref}");
+ }
+}
diff --git a/tests/Steergen.Core.PropertyTests/Packs/FileDiscoveryProperties.cs b/tests/Steergen.Core.PropertyTests/Packs/FileDiscoveryProperties.cs
new file mode 100644
index 0000000..5cd454c
--- /dev/null
+++ b/tests/Steergen.Core.PropertyTests/Packs/FileDiscoveryProperties.cs
@@ -0,0 +1,363 @@
+using CsCheck;
+using Steergen.Core.Packs;
+
+namespace Steergen.Core.PropertyTests.Packs;
+
+///
+/// Property tests for rules pack file discovery.
+///
+/// Property 11: Rules Pack File Discovery
+/// For any directory tree, the rules pack file discovery SHALL return all and only
+/// files with the .md extension found recursively under the rules root, enumerated
+/// in deterministic ordinal sort order, excluding symbolic links.
+///
+/// **Validates: Requirements 11.1**
+///
+public sealed class FileDiscoveryProperties : IDisposable
+{
+ private readonly string _tempDir;
+
+ public FileDiscoveryProperties()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), $"steergen-pbt-discovery-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDir))
+ Directory.Delete(_tempDir, recursive: true);
+ }
+
+ // ── Generators ───────────────────────────────────────────────────────────────
+
+ ///
+ /// Generates a safe directory name segment (lowercase alphanumeric, prefixed with 'd_'
+ /// to avoid conflicts with file names).
+ ///
+ private static readonly Gen GenDirSegment =
+ Gen.String[Gen.Char['a', 'z'], 1, 6]
+ .Select(s => $"d_{(s.Length == 0 ? "a" : s)}");
+
+ ///
+ /// Generates a safe file name segment (lowercase alphanumeric, prefixed with 'f_'
+ /// to avoid conflicts with directory names).
+ ///
+ private static readonly Gen GenFileNameSegment =
+ Gen.String[Gen.Char['a', 'z'], 1, 6]
+ .Select(s => $"f_{(s.Length == 0 ? "a" : s)}");
+
+ ///
+ /// Generates a file extension (including the dot). Includes .md and various non-.md extensions.
+ ///
+ private static readonly Gen GenExtension =
+ Gen.OneOf(
+ Gen.Const(".md"),
+ Gen.Const(".md"), // Weight .md more heavily to ensure coverage
+ Gen.Const(".txt"),
+ Gen.Const(".yaml"),
+ Gen.Const(".json"),
+ Gen.Const(".cs"),
+ Gen.Const(".scriban"),
+ Gen.Const(".markdown") // Similar but not .md
+ );
+
+ ///
+ /// Generates a relative path (0-3 subdirectory segments) for a file within the tree.
+ ///
+ private static readonly Gen GenSubdirPath =
+ GenDirSegment.Array[0, 3];
+
+ ///
+ /// Represents a file to be created in the test directory tree.
+ ///
+ private sealed record FileEntry(string[] SubDirs, string FileName, string Extension);
+
+ ///
+ /// Generates a single file entry with random subdirectory path, name, and extension.
+ ///
+ private static readonly Gen GenFileEntry =
+ Gen.Select(GenSubdirPath, GenFileNameSegment, GenExtension)
+ .Select((dirs, name, ext) => new FileEntry(dirs, name, ext));
+
+ ///
+ /// Generates a random directory tree specification (1-20 files).
+ ///
+ private static readonly Gen GenDirectoryTree =
+ GenFileEntry.Array[1, 20];
+
+ // ── Property: discovery returns all and only .md files ────────────────────────
+
+ [Fact]
+ public void Discovery_ReturnsAllAndOnly_MdFiles()
+ {
+ // **Validates: Requirements 11.1**
+ GenDirectoryTree
+ .Sample(
+ entries =>
+ {
+ var rootDir = CreateDirectoryTree(entries);
+
+ var discovered = RulesPackFileDiscovery.DiscoverMarkdownFiles(rootDir);
+
+ // Compute expected: all entries with .md extension
+ var expectedPaths = entries
+ .Where(e => e.Extension.Equals(".md", StringComparison.OrdinalIgnoreCase))
+ .Select(e => ComputeFilePath(rootDir, e))
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(p => p, StringComparer.Ordinal)
+ .ToList();
+
+ Assert.Equal(expectedPaths.Count, discovered.Count);
+ for (int i = 0; i < expectedPaths.Count; i++)
+ {
+ Assert.Equal(expectedPaths[i], discovered[i]);
+ }
+ },
+ iter: 100,
+ print: entries => $"files={entries.Length}, mdFiles={entries.Count(e => e.Extension == ".md")}");
+ }
+
+ // ── Property: discovery returns files in ordinal sort order ───────────────────
+
+ [Fact]
+ public void Discovery_ReturnsPaths_InOrdinalSortOrder()
+ {
+ // **Validates: Requirements 11.1**
+ GenDirectoryTree
+ .Sample(
+ entries =>
+ {
+ var rootDir = CreateDirectoryTree(entries);
+
+ var discovered = RulesPackFileDiscovery.DiscoverMarkdownFiles(rootDir);
+
+ // Verify ordinal sort order
+ var sorted = discovered.OrderBy(p => p, StringComparer.Ordinal).ToList();
+ Assert.Equal(sorted, discovered);
+ },
+ iter: 100,
+ print: entries => $"files={entries.Length}");
+ }
+
+ // ── Property: discovery never returns non-.md files ──────────────────────────
+
+ [Fact]
+ public void Discovery_NeverReturns_NonMdFiles()
+ {
+ // **Validates: Requirements 11.1**
+ GenDirectoryTree
+ .Sample(
+ entries =>
+ {
+ var rootDir = CreateDirectoryTree(entries);
+
+ var discovered = RulesPackFileDiscovery.DiscoverMarkdownFiles(rootDir);
+
+ // Every discovered file must have .md extension
+ foreach (var path in discovered)
+ {
+ Assert.True(
+ path.EndsWith(".md", StringComparison.OrdinalIgnoreCase),
+ $"Discovered file '{path}' does not have .md extension");
+ }
+ },
+ iter: 100,
+ print: entries => $"files={entries.Length}");
+ }
+
+ // ── Property: discovery is deterministic (same tree → same result) ───────────
+
+ [Fact]
+ public void Discovery_IsDeterministic_ForSameTree()
+ {
+ // **Validates: Requirements 11.1**
+ GenDirectoryTree
+ .Sample(
+ entries =>
+ {
+ var rootDir = CreateDirectoryTree(entries);
+
+ var result1 = RulesPackFileDiscovery.DiscoverMarkdownFiles(rootDir);
+ var result2 = RulesPackFileDiscovery.DiscoverMarkdownFiles(rootDir);
+
+ Assert.Equal(result1, result2);
+ },
+ iter: 100,
+ print: entries => $"files={entries.Length}");
+ }
+
+ // ── Property: discovery finds files in nested subdirectories ──────────────────
+
+ [Fact]
+ public void Discovery_FindsFiles_InNestedSubdirectories()
+ {
+ // **Validates: Requirements 11.1**
+ // Generate trees that always have at least one .md file in a subdirectory
+ Gen.Select(GenSubdirPath.Where(d => d.Length > 0), GenDirectoryTree)
+ .Sample(
+ (deepDirs, otherEntries) =>
+ {
+ // Use a unique name that won't collide with generated entries
+ var uniqueName = $"f_unique_{Guid.NewGuid():N}";
+ var deepEntry = new FileEntry(deepDirs, uniqueName, ".md");
+ var allEntries = otherEntries.Append(deepEntry).ToArray();
+
+ var rootDir = CreateDirectoryTree(allEntries);
+
+ var discovered = RulesPackFileDiscovery.DiscoverMarkdownFiles(rootDir);
+
+ var deepPath = ComputeFilePath(rootDir, deepEntry);
+ Assert.Contains(deepPath, discovered);
+ },
+ iter: 100,
+ print: t => $"deepPath depth={t.Item1.Length}, otherFiles={t.Item2.Length}");
+ }
+
+ // ── Property: empty directory returns empty list ──────────────────────────────
+
+ [Fact]
+ public void Discovery_ReturnsEmptyList_ForEmptyDirectory()
+ {
+ // **Validates: Requirements 11.1**
+ var emptyDir = Path.Combine(_tempDir, $"empty-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(emptyDir);
+
+ var discovered = RulesPackFileDiscovery.DiscoverMarkdownFiles(emptyDir);
+
+ Assert.Empty(discovered);
+ }
+
+ // ── Property: non-existent directory throws ──────────────────────────────────
+
+ [Fact]
+ public void Discovery_Throws_ForNonExistentDirectory()
+ {
+ // **Validates: Requirements 11.1**
+ var nonExistent = Path.Combine(_tempDir, "does-not-exist");
+
+ Assert.Throws(
+ () => RulesPackFileDiscovery.DiscoverMarkdownFiles(nonExistent));
+ }
+
+ // ── Property: symlinks to .md files are excluded ─────────────────────────────
+
+ [Fact]
+ public void Discovery_Excludes_SymlinksToMdFiles()
+ {
+ // **Validates: Requirements 11.1**
+ // Note: This test creates actual symlinks. On Windows, this may require
+ // developer mode or elevated privileges. If symlink creation fails,
+ // the test is skipped gracefully.
+ var rootDir = Path.Combine(_tempDir, $"symlink-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(rootDir);
+
+ // Create a real .md file
+ var realFile = Path.Combine(rootDir, "real.md");
+ File.WriteAllText(realFile, "# Real file");
+
+ // Attempt to create a symlink to the .md file
+ var symlinkPath = Path.Combine(rootDir, "linked.md");
+ try
+ {
+ File.CreateSymbolicLink(symlinkPath, realFile);
+ }
+ catch (IOException)
+ {
+ // Symlink creation not supported or insufficient privileges — skip
+ return;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Insufficient privileges — skip
+ return;
+ }
+
+ // Verify the symlink was actually created
+ if (!File.Exists(symlinkPath))
+ return;
+
+ var discovered = RulesPackFileDiscovery.DiscoverMarkdownFiles(rootDir);
+
+ // Should contain the real file but NOT the symlink
+ Assert.Contains(realFile, discovered);
+ Assert.DoesNotContain(symlinkPath, discovered);
+ }
+
+ // ── Property: symlinks to .md files in subdirectories are excluded ────────────
+
+ [Fact]
+ public void Discovery_Excludes_SymlinksInSubdirectories()
+ {
+ // **Validates: Requirements 11.1**
+ var rootDir = Path.Combine(_tempDir, $"symlink-subdir-{Guid.NewGuid():N}");
+ var subDir = Path.Combine(rootDir, "sub");
+ Directory.CreateDirectory(subDir);
+
+ // Create a real .md file in root
+ var realFile = Path.Combine(rootDir, "real.md");
+ File.WriteAllText(realFile, "# Real file");
+
+ // Create a real .md file in subdir
+ var realSubFile = Path.Combine(subDir, "sub-real.md");
+ File.WriteAllText(realSubFile, "# Sub real file");
+
+ // Attempt to create a symlink in subdir pointing to root file
+ var symlinkPath = Path.Combine(subDir, "linked.md");
+ try
+ {
+ File.CreateSymbolicLink(symlinkPath, realFile);
+ }
+ catch (IOException)
+ {
+ return;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return;
+ }
+
+ if (!File.Exists(symlinkPath))
+ return;
+
+ var discovered = RulesPackFileDiscovery.DiscoverMarkdownFiles(rootDir);
+
+ // Should contain both real files but NOT the symlink
+ Assert.Contains(realFile, discovered);
+ Assert.Contains(realSubFile, discovered);
+ Assert.DoesNotContain(symlinkPath, discovered);
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────────
+
+ ///
+ /// Creates a directory tree from the given file entries and returns the root path.
+ ///
+ private string CreateDirectoryTree(FileEntry[] entries)
+ {
+ var rootDir = Path.Combine(_tempDir, $"tree-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(rootDir);
+
+ foreach (var entry in entries)
+ {
+ var filePath = ComputeFilePath(rootDir, entry);
+ var dir = Path.GetDirectoryName(filePath)!;
+ Directory.CreateDirectory(dir);
+
+ // Only write if file doesn't already exist (handles duplicate entries)
+ if (!File.Exists(filePath))
+ File.WriteAllText(filePath, $"# Content for {entry.FileName}");
+ }
+
+ return rootDir;
+ }
+
+ ///
+ /// Computes the full file path for a given entry within the root directory.
+ ///
+ private static string ComputeFilePath(string rootDir, FileEntry entry)
+ {
+ var segments = entry.SubDirs.Append($"{entry.FileName}{entry.Extension}").ToArray();
+ return Path.Combine(rootDir, Path.Combine(segments));
+ }
+}
diff --git a/tests/Steergen.Core.PropertyTests/Packs/FileSizeLimitProperties.cs b/tests/Steergen.Core.PropertyTests/Packs/FileSizeLimitProperties.cs
new file mode 100644
index 0000000..7855a81
--- /dev/null
+++ b/tests/Steergen.Core.PropertyTests/Packs/FileSizeLimitProperties.cs
@@ -0,0 +1,243 @@
+using CsCheck;
+using Steergen.Core.Targets;
+
+namespace Steergen.Core.PropertyTests.Packs;
+
+///
+/// Property tests for file size limit enforcement in TemplateResolver.
+///
+/// Property 12: File Size Limit Enforcement
+/// For any file presented to the template resolver or rules pack loader, the file
+/// SHALL be rejected with a diagnostic error if its size exceeds 1,048,576 bytes (1 MB),
+/// and accepted for processing if its size is at or below that threshold.
+///
+/// **Validates: Requirements 14.2, 14.7**
+///
+public sealed class FileSizeLimitProperties : IDisposable
+{
+ private const long MaxFileSizeBytes = 1_048_576;
+ private const string EmbeddedSentinel = "__EMBEDDED_FALLBACK__";
+ private const string TargetId = "test-target";
+ private const string TemplateName = "document";
+
+ private readonly string _tempDir;
+
+ public FileSizeLimitProperties()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), $"steergen-pbt-filesize-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDir))
+ Directory.Delete(_tempDir, recursive: true);
+ }
+
+ // ── Generators ───────────────────────────────────────────────────────────────
+
+ ///
+ /// Generates file sizes that are at or below the 1 MB boundary (accepted range).
+ /// Focuses on sizes near the boundary for maximum coverage.
+ ///
+ private static readonly Gen GenAcceptedSize =
+ Gen.OneOf(
+ Gen.Long[1, 100], // Small files
+ Gen.Long[MaxFileSizeBytes - 100, MaxFileSizeBytes] // Near boundary (at or below)
+ );
+
+ ///
+ /// Generates file sizes that exceed the 1 MB boundary (rejected range).
+ /// Focuses on sizes just above the boundary.
+ ///
+ private static readonly Gen GenRejectedSize =
+ Gen.OneOf(
+ Gen.Long[MaxFileSizeBytes + 1, MaxFileSizeBytes + 100], // Just above boundary
+ Gen.Long[MaxFileSizeBytes + 1, MaxFileSizeBytes + 1024] // Slightly larger
+ );
+
+ // ── Property: files at or below 1 MB are accepted ────────────────────────────
+
+ [Fact]
+ public void FilesAtOrBelowLimit_AreAccepted()
+ {
+ // **Validates: Requirements 14.2, 14.7**
+ GenAcceptedSize
+ .Sample(
+ fileSize =>
+ {
+ var (resolver, expectedContent) = CreateResolverWithFile(fileSize);
+ var result = resolver.GetTemplate(TargetId, TemplateName);
+
+ Assert.Equal(expectedContent, result);
+ },
+ iter: 100,
+ print: size => $"fileSize={size} bytes");
+ }
+
+ // ── Property: files exceeding 1 MB are rejected with exception ───────────────
+
+ [Fact]
+ public void FilesExceedingLimit_AreRejectedWithException()
+ {
+ // **Validates: Requirements 14.2, 14.7**
+ GenRejectedSize
+ .Sample(
+ fileSize =>
+ {
+ var (resolver, _) = CreateResolverWithFile(fileSize);
+
+ var ex = Assert.Throws(
+ () => resolver.GetTemplate(TargetId, TemplateName));
+
+ Assert.Equal("TP002", ex.Diagnostic.Code);
+ Assert.Equal(2, ex.ExitCode);
+ },
+ iter: 100,
+ print: size => $"fileSize={size} bytes");
+ }
+
+ // ── Property: exact boundary (1,048,576 bytes) is accepted ───────────────────
+
+ [Fact]
+ public void FileAtExactBoundary_IsAccepted()
+ {
+ // **Validates: Requirements 14.2, 14.7**
+ var (resolver, expectedContent) = CreateResolverWithFile(MaxFileSizeBytes);
+ var result = resolver.GetTemplate(TargetId, TemplateName);
+
+ Assert.Equal(expectedContent, result);
+ }
+
+ // ── Property: one byte over boundary is rejected ─────────────────────────────
+
+ [Fact]
+ public void FileOneByteOverBoundary_IsRejected()
+ {
+ // **Validates: Requirements 14.2, 14.7**
+ var (resolver, _) = CreateResolverWithFile(MaxFileSizeBytes + 1);
+
+ var ex = Assert.Throws(
+ () => resolver.GetTemplate(TargetId, TemplateName));
+
+ Assert.Equal("TP002", ex.Diagnostic.Code);
+ Assert.Equal(2, ex.ExitCode);
+ }
+
+ // ── Property: size limit applies consistently across both override layers ────
+
+ [Fact]
+ public void SizeLimitApplies_ToBothLocalAndCachedLayers()
+ {
+ // **Validates: Requirements 14.2, 14.7**
+ // When local override has an oversized file, it throws immediately
+ // (does not fall through to cached layer)
+ GenRejectedSize
+ .Sample(
+ fileSize =>
+ {
+ var localDir = Path.Combine(_tempDir, $"local-{Guid.NewGuid():N}");
+ var cachedDir = Path.Combine(_tempDir, $"cached-{Guid.NewGuid():N}");
+
+ WriteTemplateFile(localDir, fileSize);
+ WriteTemplateFile(cachedDir, fileSize);
+
+ var resolver = new TemplateResolver(
+ localOverridePath: localDir,
+ cachedPackPath: cachedDir,
+ embeddedProvider: new SentinelTemplateProvider(),
+ declaredTargets: null,
+ maxFileSizeBytes: MaxFileSizeBytes);
+
+ var ex = Assert.Throws(
+ () => resolver.GetTemplate(TargetId, TemplateName));
+
+ Assert.Equal("TP002", ex.Diagnostic.Code);
+ },
+ iter: 100,
+ print: size => $"fileSize={size} bytes");
+ }
+
+ // ── Property: oversized file in cached layer also throws ─────────────────────
+
+ [Fact]
+ public void OversizedFileInCachedLayer_AlsoThrows()
+ {
+ // **Validates: Requirements 14.2, 14.7**
+ // When local override has no file but cached layer has an oversized file,
+ // the resolver throws when it encounters the oversized cached file
+ GenRejectedSize
+ .Sample(
+ fileSize =>
+ {
+ var localDir = Path.Combine(_tempDir, $"local-{Guid.NewGuid():N}");
+ var cachedDir = Path.Combine(_tempDir, $"cached-{Guid.NewGuid():N}");
+
+ // Local dir exists but has no template file for this target
+ Directory.CreateDirectory(localDir);
+ // Cached dir has the oversized file
+ WriteTemplateFile(cachedDir, fileSize);
+
+ var resolver = new TemplateResolver(
+ localOverridePath: localDir,
+ cachedPackPath: cachedDir,
+ embeddedProvider: new SentinelTemplateProvider(),
+ declaredTargets: null,
+ maxFileSizeBytes: MaxFileSizeBytes);
+
+ var ex = Assert.Throws