Skip to content

Add a specs version constraint in templates #31

@Ilyes512

Description

@Ilyes512

Context

A template may rely on features (template functions, conditional-file syntax, computed values, hook contracts, …) that only exist in certain specs CLI versions. Today there is no way for a template author to declare a required CLI version, so users on an older build silently get broken renders or confusing parse errors.

This change introduces a reserved project.yaml key that lets template authors declare a version constraint for the specs CLI. When a user runs the template, the CLI checks its own version against the declared constraint and refuses to proceed (with a clear error) if it does not match.

The reserved key is __specs__version. Unlike a "minimal version" field, this accepts a full semver constraint string so template authors can express both lower and upper bounds.

Behaviour

project.yaml may declare a constraint:

__specs__version: ^0.1.0

Accepted values are any valid Masterminds/semver constraint string, e.g.:

Value Meaning
0.1.0 exactly 0.1.0
^0.1.0 >= 0.1.0, < 0.2.0
^1.1.1 >= 1.1.1, < 2.0.0
~0.1.0 >= 0.1.0, < 0.2.0
>= 0.1.0 >= 0.1.0
>= 0.1.0, < 2.0 explicit range

Validation flow when loading a template:

  1. If the key is absent → no check, proceed.
  2. If present but not a string, or fails to parse as a semver.NewConstraint → return ErrInvalidSpecsVersion (refuse to use the template).
  3. Parse the running CLI's cmd.Version with semver.NewVersion.
    • If the CLI version is "dev" (the unset default) or otherwise unparseable, skip the check and emit a debug log (logger.Debug). Rationale: developers building from source must not be locked out, and this matches how latestSemverTag already tolerates unparseable versions.
  4. If the parsed CLI version does not satisfy the constraint → return fmt.Errorf("%w: template requires specs %s, but this binary is %s", specs.ErrSpecsVersionUnsatisfied, constraintRaw, cliVersion). Wrapping with %w is mandatory — the project-wide convention introduced on branch 50-consistent-error-wrapping is that all sentinel errors must be wrapped so callers (and output.JSONWriter) can recover them via errors.Is and KindOf.
  5. The key is consumed — it must be deleted from the user-context map so it does not leak into template rendering as .SpecsVersion or similar.

The check fires inside template.Get(), so it covers both specs use and specs template use. specs template save deliberately does not run the check — a user may want to save a newer template on an older CLI for later use or for sharing.

Files to modify

  • pkg/specs/configuration.go — add a ProjectSpecsVersionKey = "__specs__version" constant alongside ProjectDelimitersKey, and document it the same way.

  • pkg/specs/errors.go — add two sentinels alongside the existing ones:

    • ErrInvalidSpecsVersion__specs__version is present but the wrong type or not a parseable semver constraint string.
    • ErrSpecsVersionUnsatisfied — running CLI version does not satisfy the constraint.

    Both must be added as cases in KindOf() with stable kind strings invalid_specs_version and specs_version_unsatisfied (snake_case, matching the existing entries). These kind strings flow into JSON output as the error_kind field via output.JSONWriter.WriteErr, so script-friendly clients can detect them.

    Every call site that returns one of these sentinels must wrap with %w (fmt.Errorf("%w: ...", specs.ErrSpecsVersionUnsatisfied, ...)), per the convention established in branch 50-consistent-error-wrapping (e.g. ErrInvalidComputedDef, ErrCyclicDependency, ErrProjectFileMissing all follow this pattern).

  • pkg/specs/errors_test.go — extend the existing tests added in branch 50-consistent-error-wrapping:

    • TestKindOf_KnownSentinels — add bare and wrapped cases for both new sentinels.
    • TestErrorsIs_AllSentinelsWrapCorrectly — append both new sentinels to the slice.
  • pkg/template/context.go:

    • Add ExtractSpecsVersion(templateRoot string) (constraint *semver.Constraints, raw string, err error) mirroring the ExtractProjectDelimiters shape. Returns (nil, "", nil) when the key is absent.
    • In LoadUserContext, add delete(raw, specs.ProjectSpecsVersionKey) next to the existing delete(raw, specs.ProjectDelimitersKey) line so it never reaches the user context.
  • pkg/template/template.go — in Get(), after ExtractProjectDelimiters and before LoadUserContext, call a new private checkSpecsVersion(templateRoot, logger) helper that:

    • Calls ExtractSpecsVersion.
    • Compares against the CLI version passed in via Config.Version (see "wiring" below).
    • Returns the wrapped ErrSpecsVersionUnsatisfied on mismatch.
  • Wiring the running version into template.Get()pkg/template must not import pkg/cmd (would be a layering inversion). Two options:

    1. Add a Version string field to template.Config and have the cmd layer pass cmd.Version in. Empty/"dev" skips the check. Preferred — same shape as the existing Delims and SafeMode config fields.
    2. Move the Version variable from pkg/cmd into a new pkg/specs/version package and import that from pkg/template. More invasive; do this only if a second caller ends up needing it.
  • pkg/cmd/use.go and pkg/cmd/template_use.go — populate Config.Version with cmd.Version before calling template.Get.

  • Tests:

    • pkg/template/context_test.go — add cases for: missing key (ok), wrong type (error), invalid constraint string (error), valid constraint extracted, key stripped from user context.
    • pkg/template/template_test.go — add cases for: Get() succeeds when CLI version satisfies constraint, fails with ErrSpecsVersionUnsatisfied when it does not, skips the check when Config.Version is empty or "dev", fails with ErrInvalidSpecsVersion when the value is malformed.
    • pkg/cmd/use_test.go / template_use_test.go — one end-to-end case asserting the error surfaces from a real CLI invocation.
  • Docs (mandatory per AGENTS.md):

    • docs/content/docs/project-yaml.md — document the new key with a table of accepted constraint shapes.
    • docs/content/docs/architecture/template-engine.md — note the version gate as a pre-flight check during template load.
    • docs/content/docs/architecture/overview.md — add two new rows to the "Error Handling (pkg/specs/errors.go)" table for the two new sentinels and their kind strings; if it lists reserved __ keys, add __specs__version there too.
    • docs/content/docs/template-structure.md — add to the reserved-keys section if one exists alongside __delimiters.
  • README.md — update the project.yaml / reserved-keys section to mention __specs__version.

Critical files (existing patterns to mirror)

  • pkg/template/context.go:44-63ExtractProjectDelimiters is the exact shape to copy.
  • pkg/specs/configuration.go:26-33 — pattern for documenting the new reserved key constant.
  • pkg/util/git/git.go:354-375 — example of using semver.NewVersion; we will use semver.NewConstraint plus Constraints.Check(*semver.Version) here.
  • pkg/cmd/version.go:7-9Version variable, set via ldflags, defaults to "dev".

Out of scope (deliberate non-goals)

  • Validation at template save time. Saving a template that this CLI cannot currently use is a legitimate workflow.
  • A per-invocation override flag (e.g. --ignore-version-check). Can be added later if users actually ask for it; otherwise it just invites foot-guns.

Verification

  1. go test ./pkg/template/... ./pkg/specs/... ./pkg/cmd/... — unit + cmd-level tests pass.
  2. Build with an injected version: go build -ldflags "-X github.com/specsnl/specs-cli/pkg/cmd.Version=0.1.0" -o /tmp/specs ./cmd/specs.
  3. Create a tiny template with __specs__version: ^0.2.0 in its project.yaml and run /tmp/specs template use ... — confirm it fails with the expected message.
  4. Repeat with __specs__version: ^0.1.0 and confirm it succeeds.
  5. Run the same dev binary (Version="dev") and confirm the check is skipped (with a debug log when --debug is on).
  6. task lint and task test (per .github/instructions/executing-commands.md) before opening a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions