-
Notifications
You must be signed in to change notification settings - Fork 8k
fix(workflow): support integration: auto to follow project's initialized AI #2421
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
56187f3
74dcaf3
e9d946e
67e4d1f
0dd3426
ae4c94d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| """Shared filesystem paths and low-level state helpers used across the | ||
| spec-kit package. | ||
|
|
||
| This module exists so the CLI entrypoint and the workflows engine can share | ||
| canonical paths and parsing logic without duplicating string literals (which | ||
| drift) and without importing each other (which would create a circular | ||
| import on the CLI module). | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
|
|
||
| # Project-relative path to the file that records which AI integration the | ||
| # project was initialized with. Read by the CLI to dispatch commands and by | ||
| # the workflow engine to resolve the ``"auto"`` integration default. | ||
| INTEGRATION_JSON = ".specify/integration.json" | ||
|
|
||
|
|
||
| def try_read_integration_json(project_root: Path) -> dict[str, Any] | None: | ||
| """Best-effort read of ``.specify/integration.json``. | ||
|
|
||
| Returns the parsed mapping on success, or ``None`` when the file is | ||
| missing, unreadable, malformed JSON, non-UTF8, or not a JSON object. | ||
| Callers that want loud, user-facing error messages (e.g. the CLI's | ||
| :func:`specify_cli._read_integration_json`, which exits the process with | ||
| a diagnostic) should layer their own diagnostics on top instead of using | ||
| this helper directly. | ||
| """ | ||
| path = project_root / INTEGRATION_JSON | ||
| if not path.is_file(): | ||
| return None | ||
| try: | ||
| data = json.loads(path.read_text(encoding="utf-8")) | ||
| except (json.JSONDecodeError, OSError, UnicodeDecodeError): | ||
| return None | ||
| if not isinstance(data, dict): | ||
| return None | ||
| return data |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ | |
|
|
||
| import yaml | ||
|
|
||
| from .._paths import try_read_integration_json | ||
| from .base import RunStatus, StepContext, StepResult, StepStatus | ||
|
|
||
|
|
||
|
|
@@ -143,6 +144,35 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]: | |
| f"Must be 'string', 'number', or 'boolean'." | ||
| ) | ||
|
|
||
| # Validate the default eagerly so authoring mistakes (e.g. a | ||
| # default not in the declared enum, or a non-numeric default for | ||
| # a number input) surface at install/validation time instead of | ||
| # at workflow-execution time. ``"auto"`` for the integration | ||
| # input is a runtime-resolved sentinel, so only the | ||
| # enum-membership check is exempted for that exact case — the | ||
| # declared type is still enforced (e.g. ``type: number`` paired | ||
| # with ``default: "auto"`` is still rejected). | ||
| if "default" in input_def: | ||
| default_value = input_def["default"] | ||
| is_auto_integration = ( | ||
| input_name == "integration" and default_value == "auto" | ||
| ) | ||
| validation_input_def: dict[str, Any] = input_def | ||
| if is_auto_integration and "enum" in input_def: | ||
| validation_input_def = { | ||
| key: value | ||
| for key, value in input_def.items() | ||
| if key != "enum" | ||
| } | ||
| try: | ||
| WorkflowEngine._coerce_input( | ||
| input_name, default_value, validation_input_def | ||
| ) | ||
| except ValueError as exc: | ||
| errors.append( | ||
| f"Input {input_name!r} has invalid default: {exc}" | ||
| ) | ||
|
|
||
| # -- Steps ------------------------------------------------------------ | ||
| if not isinstance(definition.steps, list): | ||
| errors.append("'steps' must be a list.") | ||
|
|
@@ -715,12 +745,61 @@ def _resolve_inputs( | |
| name, provided[name], input_def | ||
| ) | ||
| elif "default" in input_def: | ||
| resolved[name] = input_def["default"] | ||
| default_value = self._resolve_default(name, input_def["default"]) | ||
| # If the integration default could not be resolved against | ||
| # project state and falls back to the literal ``"auto"`` | ||
| # sentinel, exempt it from enum-membership coercion so a | ||
| # workflow that lists specific integrations in ``enum`` does | ||
| # not crash at runtime — declared type is still enforced. | ||
| coerce_input_def = input_def | ||
| if ( | ||
| name == "integration" | ||
| and default_value == "auto" | ||
| and "enum" in input_def | ||
| ): | ||
| coerce_input_def = { | ||
| key: value | ||
| for key, value in input_def.items() | ||
| if key != "enum" | ||
| } | ||
| resolved[name] = self._coerce_input( | ||
| name, default_value, coerce_input_def | ||
| ) | ||
| elif input_def.get("required", False): | ||
| msg = f"Required input {name!r} not provided." | ||
| raise ValueError(msg) | ||
| return resolved | ||
|
|
||
| def _resolve_default(self, name: str, default: Any) -> Any: | ||
| """Resolve special default sentinels against project state. | ||
|
|
||
| For the ``integration`` input, ``"auto"`` resolves to the integration | ||
| recorded in ``.specify/integration.json`` so workflows dispatch to the | ||
| AI the project was actually initialized with, instead of a hardcoded | ||
| value baked into the workflow YAML. | ||
| """ | ||
| if name == "integration" and default == "auto": | ||
| resolved = self._load_project_integration() | ||
| if resolved is not None: | ||
| return resolved | ||
| return default | ||
|
|
||
| def _load_project_integration(self) -> str | None: | ||
| """Read the active integration key from ``.specify/integration.json``. | ||
|
|
||
| Returns None when the file is missing or malformed; callers are | ||
| expected to fall back to a literal default. The low-level read is | ||
| delegated to :func:`specify_cli._paths.try_read_integration_json` | ||
| so the parsing rules stay aligned with the CLI's loader. | ||
| """ | ||
| data = try_read_integration_json(self.project_root) | ||
| if data is None: | ||
| return None | ||
| value = data.get("integration") | ||
| if isinstance(value, str) and value: | ||
| return value | ||
| return None | ||
|
Comment on lines
+787
to
+801
|
||
|
|
||
| @staticmethod | ||
| def _coerce_input( | ||
| name: str, value: Any, input_def: dict[str, Any] | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_resolve_inputsnow coerces/validates default values via_coerce_input, which is a user-visible behavior change (invalid defaults can now raise at runtime). Sincevalidate_workflow()doesn’t currently validate defaults against declared types/enums, consider adding that validation there so workflows fail fast at install/validation time rather than during execution.