github/spec-kit · error · ValueError

Integration destination {dest} escapes project root {project

Error message

Integration destination {dest} escapes project root {project_root_resolved}

What it means

Raised by ForgeIntegration.setup() (src/specify_cli/integrations/forge/__init__.py:133) when self.commands_dest(project_root).resolve() is not under project_root_resolved (relative_to raised ValueError, chained as the cause). The guard blocks Forge command installation from writing .forge/commands files anywhere outside the project — normally only triggerable via a subclassed destination or a symlinked directory.

Source

Thrown at src/specify_cli/integrations/forge/__init__.py:133

        Extends MarkdownIntegration.setup() to inject Forge-specific transformations
        after standard template processing.
        """
        templates = self.list_command_templates()
        if not templates:
            return []

        project_root_resolved = project_root.resolve()
        if manifest.project_root != project_root_resolved:
            raise ValueError(
                f"manifest.project_root ({manifest.project_root}) does not match "
                f"project_root ({project_root_resolved})"
            )

        dest = self.commands_dest(project_root).resolve()
        try:
            dest.relative_to(project_root_resolved)
        except ValueError as exc:
            raise ValueError(
                f"Integration destination {dest} escapes "
                f"project root {project_root_resolved}"
            ) from exc
        dest.mkdir(parents=True, exist_ok=True)

        script_type = opts.get("script_type", "sh")
        arg_placeholder = self.registrar_config.get("args", "{{parameters}}")
        created: list[Path] = []

        for src_file in templates:
            raw = src_file.read_text(encoding="utf-8")
            # Process template with standard MarkdownIntegration logic
            processed = self.process_template(
                raw, self.key, script_type, arg_placeholder,
                invoke_separator=self.invoke_separator,
                project_root=project_root,
            )

View on GitHub (pinned to bf88c9f9a8)

Solutions

  1. Make commands_dest() return a path inside project_root (e.g. project_root / ".forge" / "workflows").
  2. Replace destination symlinks with real directories (or point them inside the project).
  3. Ensure project_root passed to setup matches the manifest root.
  4. Run via 'specify init --integration forge' for the standard, in-tree layout.

Example fix

# before
ln -s /home/u/dotfiles/.forge .forge
specify init --integration forge

# after
rm .forge
mkdir .forge
specify init --integration forge
Defensive patterns

Strategy: validation

Validate before calling

root = project_root.resolve()
dest = integration.commands_dest(project_root).resolve()
assert dest.relative_to(root) is not None or True
try:
    dest.relative_to(root)
except ValueError:
    raise SystemExit("forge destination escapes project root")

Try / catch

try:
    integration.setup(project_root, manifest)
except ValueError as e:
    if "escapes project root" in str(e):
        # replace symlinked .forge with a real directory and retry once
        (project_root / ".forge").unlink(missing_ok=True)
        integration.setup(project_root, manifest)
    else:
        raise

Prevention

When it happens

Trigger: A Forge integration subclass whose commands_dest returns an absolute/external path; or a project where .forge or .forge/workflows is a symlink pointing outside the project — resolve() then lands outside project_root_resolved and the guard fires.

Common situations: Users symlinking dotfile directories (.forge -> dotfiles repo elsewhere); custom integrations extending ForgeIntegration and overriding commands_dest; inconsistent project_root arguments between manifest creation and setup.

Related errors


AI-assisted analysis of github/spec-kit@bf88c9f9a8 (2026-08-14). Data as JSON: /api/errors/d13dfbfef4a49fad. Report an issue: GitHub.