mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04fd3b8033 | ||
|
|
70c547cfab | ||
|
|
2f9e45514c | ||
|
|
69c8b64301 | ||
|
|
5760061316 | ||
|
|
1f7290c975 | ||
|
|
2a0ada9a6a | ||
|
|
7873c447bd | ||
|
|
eabfabb490 | ||
|
|
74662cffad | ||
|
|
7f97f1f1f8 | ||
|
|
3b611575b2 | ||
|
|
ec45dbd791 | ||
|
|
d6fa0460ed | ||
|
|
8a5bcc21a5 | ||
|
|
75d37389c8 | ||
|
|
6d77b4a099 | ||
|
|
57cc518d63 | ||
|
|
eb2252a1cb | ||
|
|
2df0394cb2 | ||
|
|
3d2901eb75 | ||
|
|
c1e5cfa0aa | ||
|
|
b139bd0393 | ||
|
|
f75f5f836b | ||
|
|
c864fc7447 | ||
|
|
848e41bc92 |
20
.github/aw/actions-lock.json
vendored
20
.github/aw/actions-lock.json
vendored
@@ -1,10 +1,30 @@
|
||||
{
|
||||
"entries": {
|
||||
"actions/checkout@v6.0.3": {
|
||||
"repo": "actions/checkout",
|
||||
"version": "v6.0.3",
|
||||
"sha": "df4cb1c069e1874edd31b4311f1884172cec0e10"
|
||||
},
|
||||
"actions/download-artifact@v8.0.1": {
|
||||
"repo": "actions/download-artifact",
|
||||
"version": "v8.0.1",
|
||||
"sha": "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"
|
||||
},
|
||||
"actions/github-script@v9.0.0": {
|
||||
"repo": "actions/github-script",
|
||||
"version": "v9.0.0",
|
||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||
},
|
||||
"actions/setup-node@v6.4.0": {
|
||||
"repo": "actions/setup-node",
|
||||
"version": "v6.4.0",
|
||||
"sha": "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"
|
||||
},
|
||||
"actions/upload-artifact@v7.0.1": {
|
||||
"repo": "actions/upload-artifact",
|
||||
"version": "v7.0.1",
|
||||
"sha": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
|
||||
},
|
||||
"github/gh-aw-actions/setup@v0.79.8": {
|
||||
"repo": "github/gh-aw-actions/setup",
|
||||
"version": "v0.79.8",
|
||||
|
||||
1746
.github/workflows/add-community-bundle.lock.yml
generated
vendored
Normal file
1746
.github/workflows/add-community-bundle.lock.yml
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
288
.github/workflows/add-community-bundle.md
vendored
Normal file
288
.github/workflows/add-community-bundle.md
vendored
Normal file
@@ -0,0 +1,288 @@
|
||||
---
|
||||
description: "Process community bundle submission issues - validate, add to catalog, and open a PR for maintainer review"
|
||||
emoji: "📦"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
names: [bundle-submission]
|
||||
skip-bots: [github-actions, copilot, dependabot]
|
||||
|
||||
tools:
|
||||
edit:
|
||||
bash: ["echo", "grep", "sort", "python3", "jq", "date"]
|
||||
github:
|
||||
toolsets: [issues, repos]
|
||||
min-integrity: none
|
||||
web-fetch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
|
||||
checkout:
|
||||
fetch-depth: 0
|
||||
|
||||
safe-outputs:
|
||||
noop:
|
||||
report-as-issue: false
|
||||
create-pull-request:
|
||||
title-prefix: "[bundle] "
|
||||
labels: [bundle-submission, automated]
|
||||
draft: true
|
||||
max: 1
|
||||
allowed-files:
|
||||
- bundles/catalog.community.json
|
||||
- docs/community/bundles.md
|
||||
protected-files:
|
||||
policy: blocked
|
||||
exclude:
|
||||
- README.md
|
||||
- CHANGELOG.md
|
||||
add-comment:
|
||||
max: 2
|
||||
add-labels:
|
||||
allowed: [bundle-submission, validation-passed, validation-failed, needs-info]
|
||||
max: 3
|
||||
remove-labels:
|
||||
allowed: [validation-passed, validation-failed, needs-info]
|
||||
---
|
||||
|
||||
# Add Community Bundle from Issue Submission
|
||||
|
||||
You are a catalog maintenance agent for the Spec Kit project. Process community
|
||||
bundle submission issues and create draft pull requests that add or update
|
||||
entries in the community bundle catalog.
|
||||
|
||||
Community bundles are untrusted. Validate metadata and distribution evidence,
|
||||
but do not claim to audit, endorse, or support bundle code or the components it
|
||||
installs. Never register a submitted companion catalog automatically.
|
||||
|
||||
## Triggering Conditions
|
||||
|
||||
This workflow is triggered by an `issues: labeled` event and is gated to the
|
||||
`bundle-submission` label. Before processing, verify that the issue title starts
|
||||
with `[Bundle]:`. If it does not, stop without commenting.
|
||||
|
||||
## Step 1 - Read and Parse the Issue
|
||||
|
||||
Read issue #${{ github.event.issue.number }} and extract these issue-form fields:
|
||||
|
||||
| Field | Issue Form ID | Required |
|
||||
|-------|---------------|----------|
|
||||
| Bundle ID | `bundle-id` | Yes |
|
||||
| Bundle Name | `bundle-name` | Yes |
|
||||
| Version | `version` | Yes |
|
||||
| Role or Team | `role` | Yes |
|
||||
| Description | `description` | Yes |
|
||||
| Author | `author` | Yes |
|
||||
| Repository URL | `repository` | Yes |
|
||||
| Download URL | `download-url` | Yes |
|
||||
| Documentation URL | `documentation` | Yes |
|
||||
| License | `license` | Yes |
|
||||
| Required Spec Kit Version | `speckit-version` | Yes |
|
||||
| Integration Target | `integration` | No |
|
||||
| Components Provided | `components-provided` | Yes |
|
||||
| Required Component Catalogs | `required-catalogs` | Yes |
|
||||
| Tags | `tags` | Yes |
|
||||
| Key Features | `features` | Yes |
|
||||
| Testing Details | `testing-details` | Yes |
|
||||
| Example Usage | `example-usage` | Yes |
|
||||
| Proposed Catalog Entry | `catalog-entry` | Yes |
|
||||
|
||||
Issue-form values appear beneath headings matching their labels.
|
||||
|
||||
## Step 2 - Validate the Submission
|
||||
|
||||
Run every check and collect all failures before deciding the outcome.
|
||||
|
||||
### 2a. Bundle ID and version
|
||||
|
||||
- The bundle ID must match
|
||||
`^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$`.
|
||||
- The version must be semantic version `X.Y.Z` with digits only and no `v`
|
||||
prefix.
|
||||
|
||||
### 2b. Repository and documentation
|
||||
|
||||
- Restrict repository and documentation URLs to public GitHub URLs before
|
||||
fetching them.
|
||||
- Confirm the repository exists and contains `bundle.yml`, `README.md`, and a
|
||||
license file (`LICENSE`, `LICENSE.md`, or `LICENSE.txt`).
|
||||
- The documentation URL must resolve to a readable Markdown file that explains
|
||||
the bundle's intended role, installed components, required catalogs, and
|
||||
installation steps.
|
||||
- Confirm the repository's `bundle.yml` matches the submitted bundle ID,
|
||||
version, role, author, license, Spec Kit requirement, integration target, and
|
||||
component summary.
|
||||
|
||||
### 2c. Release artifact
|
||||
|
||||
- The download URL must be an HTTPS GitHub release asset URL under the submitted
|
||||
repository:
|
||||
`https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>.zip`.
|
||||
- Confirm the release exists, its tag corresponds to the submitted version
|
||||
(`vX.Y.Z` or `X.Y.Z`), and the exact ZIP asset is attached to that release.
|
||||
- Confirm the asset name is versioned and consistent with the submitted bundle
|
||||
ID and version.
|
||||
|
||||
Do not fetch arbitrary user-provided URLs. Do not claim the artifact was
|
||||
executed or audited; rely on the required submission attestations for build and
|
||||
installation evidence.
|
||||
|
||||
### 2d. Catalog entry
|
||||
|
||||
Parse the proposed JSON and require one entry under the submitted bundle ID.
|
||||
Confirm that:
|
||||
|
||||
- `id`, `name`, `version`, `role`, `description`, `author`, `license`,
|
||||
`download_url`, and `repository` match the submission and manifest.
|
||||
- `requires.speckit_version` matches the submission.
|
||||
- `provides` contains non-negative integer counts for `extensions`, `presets`,
|
||||
`steps`, and `workflows`, matching the manifest.
|
||||
- `tags` contains 2-5 lowercase strings and matches the submitted tags.
|
||||
- `verified` is the boolean value `false`. Community entries must never be
|
||||
marked verified.
|
||||
|
||||
### 2e. Component resolution
|
||||
|
||||
- `Required Component Catalogs` must explicitly say `None` or list every
|
||||
non-default extension, preset, workflow, and step catalog needed by the
|
||||
bundle.
|
||||
- Compare the manifest references, README, required-catalog field, testing
|
||||
details, and example usage for consistency.
|
||||
- If non-default catalogs are required, ensure each URL is HTTPS, the README
|
||||
documents the corresponding `catalog add` command, and the testing details
|
||||
say those catalogs were registered in the clean-project test.
|
||||
- If the field says `None` but a component is not bundled and cannot be
|
||||
installed from a default Spec Kit catalog, fail validation and ask the
|
||||
submitter to list and document an install-allowed companion catalog.
|
||||
|
||||
The community bundle catalog itself remains discovery-only. Companion catalog
|
||||
URLs are documentation and validation metadata, not catalogs this workflow
|
||||
should add to Spec Kit.
|
||||
|
||||
### 2f. Checklists and testing evidence
|
||||
|
||||
- Confirm every required checkbox in Testing Checklist and Submission
|
||||
Requirements is checked (`[x]`).
|
||||
- Confirm Testing Details describe validation, build, artifact installation,
|
||||
and clean-project testing.
|
||||
- Confirm Example Usage includes artifact installation and, when applicable,
|
||||
all required catalog setup commands.
|
||||
|
||||
### Validation outcome
|
||||
|
||||
If any check fails:
|
||||
|
||||
1. Comment once with every failed check and a specific correction.
|
||||
2. Remove `validation-passed`.
|
||||
3. Add `validation-failed`; add `needs-info` when submitter input is needed.
|
||||
4. Stop without editing files or creating a pull request.
|
||||
|
||||
If all checks pass, remove `validation-failed` and `needs-info`, add
|
||||
`validation-passed`, and continue.
|
||||
|
||||
## Step 3 - Determine Add or Update
|
||||
|
||||
Search `bundles/catalog.community.json` for the bundle ID.
|
||||
|
||||
- If absent, add a new entry.
|
||||
- If present, update the existing entry in place.
|
||||
|
||||
Treat a submitted version lower than or equal to the existing catalog version
|
||||
as a validation failure unless the issue clearly documents a metadata-only
|
||||
correction at the same version.
|
||||
|
||||
## Step 4 - Update the Community Catalog
|
||||
|
||||
Edit `bundles/catalog.community.json`. Insert new entries alphabetically by
|
||||
bundle ID. The entry shape is:
|
||||
|
||||
```json
|
||||
{
|
||||
"<bundle-id>": {
|
||||
"name": "<bundle-name>",
|
||||
"id": "<bundle-id>",
|
||||
"version": "<version>",
|
||||
"role": "<role>",
|
||||
"description": "<description>",
|
||||
"author": "<author>",
|
||||
"license": "<license>",
|
||||
"download_url": "<download-url>",
|
||||
"repository": "<repository>",
|
||||
"requires": {
|
||||
"speckit_version": "<speckit-version>"
|
||||
},
|
||||
"provides": {
|
||||
"extensions": 0,
|
||||
"presets": 0,
|
||||
"steps": 0,
|
||||
"workflows": 0
|
||||
},
|
||||
"tags": ["<tag>"],
|
||||
"verified": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use the validated proposed entry rather than inventing metadata. Keep
|
||||
`verified: false`. Update the top-level `updated_at` to today's UTC date at
|
||||
midnight and preserve the top-level `catalog_url`.
|
||||
|
||||
Validate the complete file:
|
||||
|
||||
```bash
|
||||
python3 -c "import json; json.load(open('bundles/catalog.community.json')); print('Valid JSON')"
|
||||
```
|
||||
|
||||
## Step 5 - Update Community Documentation
|
||||
|
||||
Add or update the bundle in `docs/community/bundles.md`. Keep rows alphabetical
|
||||
by bundle name:
|
||||
|
||||
```text
|
||||
| <Name> | <Description> | `<role>` | <component counts> | <None or documented> | [<repo-name>](<repository>) |
|
||||
```
|
||||
|
||||
Before rendering the row, convert every user-derived display value to
|
||||
single-line plain text: collapse CR/LF sequences to spaces, remove control
|
||||
characters, and backslash-escape `\`, `|`, backticks, `*`, `_`, `[`, `]`, `<`,
|
||||
and `>`. Use the validated HTTPS GitHub repository URL unchanged only as the
|
||||
Markdown link destination.
|
||||
|
||||
Render component counts compactly, omitting zero-valued component types. Use
|
||||
`None` when no companion catalogs are needed and `Documented` otherwise; the
|
||||
repository README remains the source for the actual URLs.
|
||||
|
||||
## Step 6 - Create a Draft Pull Request
|
||||
|
||||
Create one draft pull request.
|
||||
|
||||
- New entry branch:
|
||||
`community/${{ github.event.issue.number }}-add-<bundle-id>-bundle`
|
||||
- Update branch:
|
||||
`community/${{ github.event.issue.number }}-update-<bundle-id>-bundle`
|
||||
- New title: `Add <Bundle Name> bundle to community catalog`
|
||||
- Update title: `Update <Bundle Name> bundle to v<version>`
|
||||
|
||||
The commit and PR description must summarize the catalog and documentation
|
||||
changes, list the validation results, include
|
||||
`Closes #${{ github.event.issue.number }}`, and mention the submitter with
|
||||
`cc @<issue-author>`.
|
||||
|
||||
End the commit message with this authorship trailer:
|
||||
|
||||
```text
|
||||
Assisted-by: GitHub Copilot (model: <name-if-known>, autonomous)
|
||||
```
|
||||
|
||||
## Important Rules
|
||||
|
||||
- Modify only `bundles/catalog.community.json` and
|
||||
`docs/community/bundles.md`.
|
||||
- Keep JSON entries sorted by ID and documentation rows sorted by name.
|
||||
- Never set a community bundle's `verified` field to true.
|
||||
- Never add, enable, or change the policy of a submitted catalog.
|
||||
- Never describe validation as a security audit or endorsement.
|
||||
- Use `Closes`, not `Fixes`, for the submission issue.
|
||||
6
.github/workflows/catalog-assign.yml
vendored
6
.github/workflows/catalog-assign.yml
vendored
@@ -9,11 +9,13 @@ jobs:
|
||||
if: >
|
||||
(github.event.action == 'opened' && (
|
||||
contains(github.event.issue.labels.*.name, 'extension-submission') ||
|
||||
contains(github.event.issue.labels.*.name, 'preset-submission')
|
||||
contains(github.event.issue.labels.*.name, 'preset-submission') ||
|
||||
contains(github.event.issue.labels.*.name, 'bundle-submission')
|
||||
)) ||
|
||||
(github.event.action == 'labeled' && (
|
||||
github.event.label.name == 'extension-submission' ||
|
||||
github.event.label.name == 'preset-submission'
|
||||
github.event.label.name == 'preset-submission' ||
|
||||
github.event.label.name == 'bundle-submission'
|
||||
))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Run ruff check
|
||||
run: uvx ruff check src/
|
||||
run: uvx ruff check src tests
|
||||
|
||||
pytest:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
35
CHANGELOG.md
35
CHANGELOG.md
@@ -2,6 +2,41 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.13.2] - 2026-07-21
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(workflows): reject a non-string 'command' in command-step (#3596)
|
||||
- fix(workflows): fail gate step loudly on a malformed 'options' (#3595)
|
||||
- fix(extensions): re-validate catalog URL after redirects (HTTPS parity/security) (#3524)
|
||||
- Add community bundle submission automation (#3553)
|
||||
- fix(presets): re-validate catalog URL after redirects (HTTPS parity/security) (#3523)
|
||||
- feat(scripts): port create-new-feature, setup-plan and setup-tasks to Python (#3386)
|
||||
- fix(agents): parse frontmatter on the --- delimiter line, not any --- substring (#3590)
|
||||
- [bug-fix] Fix reinstall-overwrites-kept-config: preserve config on plain reinstall after --keep-config (#3449)
|
||||
- feat: update Bob integration to skills-based layout for Bob 2.0 (#3415)
|
||||
- Update OKF Knowledge Bundle Generator to v0.3.0 (#3608)
|
||||
- Add Test Coverage Drift Control extension to community catalog (#3607)
|
||||
- chore: align ruff lint scope (#3139)
|
||||
- feat(workflows): WorkflowResolver standalone (PR 1) (#3557)
|
||||
- fix(extensions,presets): surface clean error on malformed download URL (#3577)
|
||||
- chore: release 0.13.1, begin 0.13.2.dev0 development (#3610)
|
||||
|
||||
## [0.13.1] - 2026-07-21
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(integrations): catch OverflowError on a `priority: .inf` in add/remove (#3589)
|
||||
- fix(workflows): reject bool / .inf catalog priority in workflow & step catalog loaders (#3526)
|
||||
- fix(catalogs): 'priority: .inf' yields a clean validation error instead of crashing (#3525)
|
||||
- docs(integrations): document the 'integration list --catalog' flag (#3530)
|
||||
- fix(workflows): fail fan-in loudly on a non-string wait_for entry (#3579)
|
||||
- fix(workflows): fail fan-out loudly on a truthy non-mapping step template (#3537)
|
||||
- fix(workflows): reject a non-string prompt in prompt-step validate() (#3582)
|
||||
- fix(workflows): route 'workflow status --json' errors to stderr (#3520)
|
||||
- fix(integrations): Forge dispatches hyphenated /speckit-<cmd> invocations (#3529)
|
||||
- chore: release 0.13.0, begin 0.13.1.dev0 development (#3588)
|
||||
|
||||
## [0.13.0] - 2026-07-17
|
||||
|
||||
### Changed
|
||||
|
||||
6
bundles/catalog.community.json
Normal file
6
bundles/catalog.community.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-15T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.community.json",
|
||||
"bundles": {}
|
||||
}
|
||||
@@ -5,7 +5,10 @@
|
||||
|
||||
Bundles compose existing Spec Kit components — extensions, presets, workflows, and steps — into a single role or team stack. They are useful when a user should be able to install a tested set of components together instead of following several separate install commands.
|
||||
|
||||
Accepted community bundle entries will be listed here once a community bundle catalog is available. To submit a bundle for review, file a [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue.
|
||||
Accepted community bundle entries are published in [`bundles/catalog.community.json`](https://github.com/github/spec-kit/blob/main/bundles/catalog.community.json) and listed below. The built-in community source is discovery-only: `specify bundle search` and `specify bundle info` can inspect entries, but installing by ID requires explicitly adding an install-allowed catalog. Explicit catalogs use a higher default precedence than the built-in community source. To submit a bundle for review, file a [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue.
|
||||
|
||||
| Bundle | Purpose | Role or team | Provides | Required catalogs | URL |
|
||||
|--------|---------|--------------|----------|-------------------|-----|
|
||||
|
||||
## What to Submit
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Multi-Repo Branch Sync | Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks | `process` | Read+Write | [multi-repo-sync](https://github.com/fyloss/spec-kit-multi-repo-sync) |
|
||||
| Multi-Sites Spec Kit | Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support | `process` | Read+Write | [spec-kit-multi-sites](https://github.com/teeyo/spec-kit-multi-sites) |
|
||||
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
|
||||
| OKF Knowledge Bundle Generator | Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository | `docs` | Read+Write | [speckit_ofk](https://github.com/alexcpn/speckit_ofk) |
|
||||
| OKF Knowledge Bundle Generator | Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository, mining git history for significance and rationale, and resolving open questions with the user | `docs` | Read+Write | [speckit_ofk](https://github.com/alexcpn/speckit_ofk) |
|
||||
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
|
||||
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
|
||||
| Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) |
|
||||
@@ -149,6 +149,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| Tasks to GitHub Project | Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board. | `integration` | Read+Write | [spec-kit-tasks-to-project](https://github.com/mancioshell/spec-kit-tasks-to-project) |
|
||||
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
|
||||
| Test Coverage Drift Control | Generate incremental coverage drift reports and planned remediation tasks after implementation | `code` | Read+Write | [spec-kit-test-coverage-drift-control](https://github.com/benizzio/spec-kit-test-coverage-drift-control) |
|
||||
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
|
||||
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
|
||||
| Token Budget | Reduces LLM token consumption in Spec Kit workflows: compact artifacts in-place, scope per-phase reading, suppress prose padding, and report token usage | `process` | Read+Write | [spec-kit-token-budget](https://github.com/tinesoft/spec-kit-token-budget) |
|
||||
|
||||
@@ -120,10 +120,10 @@ generated metadata, then add the import and `_register()` call in
|
||||
|
||||
## 7. Run Lint / Basic Checks
|
||||
|
||||
CI enforces `ruff check src/` (see `.github/workflows/test.yml`), so run it locally before pushing:
|
||||
CI enforces `ruff check src tests` (see `.github/workflows/test.yml`), so run it locally before pushing:
|
||||
|
||||
```bash
|
||||
uvx ruff check src/
|
||||
uvx ruff check src tests
|
||||
```
|
||||
|
||||
You can also quickly sanity check importability:
|
||||
|
||||
@@ -22,7 +22,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
|
||||
| [Grok Build](https://docs.x.ai/build/overview) | `grok` | Skills-based integration; installs skills into `.grok/skills` and invokes them as `/speckit-<command>` |
|
||||
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
|
||||
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
|
||||
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | Skills-based integration by default; installs skills as `speckit-<command>/SKILL.md` under `.bob/skills/` and invokes them as `/speckit-<command>`. Pass `--integration-options="--legacy-commands"` to scaffold the deprecated Bob 1.x layout (`.bob/commands/*.md`) instead; that flag will be removed in a future release. Existing legacy installs can migrate with `specify integration upgrade bob --integration-options="--skills"`, which converts them to the skills layout and removes the old command files. If preset overrides are installed, the migration is rejected with an actionable error (preset artifacts cannot yet be reconciled across a layout change) — remove the preset(s), migrate, then reinstall them. |
|
||||
| [Junie](https://junie.jetbrains.com/) | `junie` | |
|
||||
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
|
||||
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths |
|
||||
@@ -48,7 +48,11 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
specify integration list
|
||||
```
|
||||
|
||||
Shows all available integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based.
|
||||
| Option | Description |
|
||||
| ----------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--catalog` | Also browse the catalog (built-in **and** community). Community integrations that are not built in are only shown here. |
|
||||
|
||||
Shows the built-in integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based.
|
||||
When multiple integrations are installed, the list marks the default integration separately from the other installed integrations.
|
||||
The list also shows whether each built-in integration is declared multi-install safe.
|
||||
|
||||
|
||||
@@ -91,8 +91,192 @@ specify workflow add <source>
|
||||
| `--dev` | Install from a local workflow YAML file or directory |
|
||||
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
|
||||
|
||||
Installs a workflow from the catalog, a URL (HTTPS required), or a local file path.
|
||||
Installs a workflow from the catalog, a URL (HTTPS required), a local YAML file, or a local directory containing `workflow.yml`.
|
||||
|
||||
## Workflow Overlays
|
||||
|
||||
Workflow overlays let a project extend or override an installed workflow without editing the installed `workflow.yml`. This keeps local customizations safe across `specify bundle update` or `specify workflow add` upgrades.
|
||||
|
||||
When `specify workflow run <workflow-id>` loads a workflow, the engine composes the base workflow with all enabled overlays for that workflow id. The result is validated like any other workflow definition.
|
||||
|
||||
### How Overlays Work
|
||||
|
||||
An overlay is a YAML file that declares a set of edit operations against the step list of a base workflow. Overlays use lower-wins precedence: higher priority numbers are applied first and lower numbers last. Equal-priority overlays are applied alphabetically by ID, with the last ID winning conflicts.
|
||||
|
||||
Project overlay files live at:
|
||||
|
||||
| Location | Purpose |
|
||||
| --- | --- |
|
||||
| `.specify/workflows/overlays/<id>/*.yml` | Project-local customizations |
|
||||
|
||||
### Overlay File Format
|
||||
|
||||
The recommended edit format uses the operation name as the key and the anchor step id as the value:
|
||||
|
||||
```yaml
|
||||
id: "my-overlay"
|
||||
extends: "speckit"
|
||||
priority: 10
|
||||
enabled: true
|
||||
edits:
|
||||
- insert_after: implement
|
||||
step:
|
||||
id: run-lint
|
||||
type: shell
|
||||
run: "ruff check src/"
|
||||
|
||||
- replace: review-spec
|
||||
step:
|
||||
id: review-spec
|
||||
type: gate
|
||||
message: "Review the generated spec (overlay override)."
|
||||
options: [approve, reject]
|
||||
on_reject: abort
|
||||
```
|
||||
|
||||
The explicit form is also supported:
|
||||
|
||||
```yaml
|
||||
edits:
|
||||
- operation: insert_after
|
||||
anchor: implement
|
||||
step:
|
||||
id: run-lint
|
||||
type: shell
|
||||
run: "ruff check src/"
|
||||
```
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `id` | yes | Identifier for this overlay. Used in `specify workflow overlay *` commands. Must be lowercase letters, digits, and hyphens only; no dots, underscores, path separators, or `overlays`. |
|
||||
| `extends` | yes | The workflow id this overlay applies to. Uses the same safe-id format as `id`; `overlays`, `runs`, and `steps` are reserved. |
|
||||
| `priority` | no | Integer; defaults to `10`. Lower values have higher precedence and win conflicts. Missing or invalid values fall back to `10`. |
|
||||
| `enabled` | no | Boolean. Defaults to `true`. Disabled overlays are ignored. |
|
||||
| `edits` | yes | Non-empty list of edit operations. |
|
||||
|
||||
#### Edit Operations
|
||||
|
||||
| Operation | `step` required | Effect |
|
||||
| --- | --- | --- |
|
||||
| `insert_after` | yes | Insert `step` immediately after the anchor step. |
|
||||
| `insert_before` | yes | Insert `step` immediately before the anchor step. |
|
||||
| `replace` | yes | Replace the anchor step with `step`. |
|
||||
| `remove` | no | Remove the anchor step from the list. |
|
||||
|
||||
The `anchor` is the `id` of a step in the base workflow. Anchors are resolved recursively inside `then`, `else`, `steps`, `cases.*`, and `default` blocks, so nested base steps can also be targeted. Fan-out templates (`step` inside a `fan-out` step) are **not** valid anchors.
|
||||
|
||||
Step ids must not contain `:` — that character is reserved for engine-generated nested ids.
|
||||
|
||||
### Overlay CLI Commands
|
||||
|
||||
#### Add a Project Overlay
|
||||
|
||||
```bash
|
||||
specify workflow overlay add <path-to-overlay.yml> --priority <n>
|
||||
```
|
||||
|
||||
Validates the overlay file and copies it to `.specify/workflows/overlays/<extends>/<id>.yml`. `--priority` defaults to `10` and overrides the `priority` field in the file.
|
||||
|
||||
#### List Overlays
|
||||
|
||||
```bash
|
||||
specify workflow overlay list <workflow-id>
|
||||
```
|
||||
|
||||
Shows all overlays for the workflow, ordered by resolver precedence. Disabled overlays are marked as disabled in the listing and are ignored during workflow resolution.
|
||||
|
||||
#### Change Priority
|
||||
|
||||
```bash
|
||||
specify workflow overlay set-priority <workflow-id> <overlay-id> <n>
|
||||
```
|
||||
|
||||
#### Enable or Disable
|
||||
|
||||
```bash
|
||||
specify workflow overlay disable <workflow-id> <overlay-id>
|
||||
specify workflow overlay enable <workflow-id> <overlay-id>
|
||||
```
|
||||
|
||||
#### Remove
|
||||
|
||||
```bash
|
||||
specify workflow overlay remove <workflow-id> <overlay-id>
|
||||
```
|
||||
|
||||
Removes the project overlay file.
|
||||
|
||||
#### Inspect the Composed Workflow
|
||||
|
||||
```bash
|
||||
specify workflow resolve <workflow-id>
|
||||
```
|
||||
|
||||
Prints the layer stack (base + overlays) and the source attribution for each step after composition. Useful for debugging which overlay contributed or overrode a step.
|
||||
|
||||
### Example: Adding Automated Linting after Implementation
|
||||
|
||||
Given the built-in `speckit` workflow, create `project-overlay.yml`:
|
||||
|
||||
```yaml
|
||||
id: "add-lint"
|
||||
extends: "speckit"
|
||||
priority: 10
|
||||
edits:
|
||||
- insert_after: implement
|
||||
step:
|
||||
id: run-lint
|
||||
type: shell
|
||||
run: "ruff check src/"
|
||||
```
|
||||
|
||||
Install it:
|
||||
|
||||
```bash
|
||||
specify workflow overlay add project-overlay.yml --priority 10
|
||||
```
|
||||
|
||||
Run the workflow:
|
||||
|
||||
```bash
|
||||
specify workflow run speckit -i spec="Build a kanban board"
|
||||
```
|
||||
|
||||
The composed workflow will now run the full SDD cycle and execute `ruff check src/` automatically after the `implement` step.
|
||||
|
||||
### Example: Replacing a Gate
|
||||
|
||||
```yaml
|
||||
id: "skip-plan-review"
|
||||
extends: "speckit"
|
||||
priority: 5
|
||||
edits:
|
||||
- replace: review-plan
|
||||
step:
|
||||
id: review-plan
|
||||
type: command
|
||||
command: speckit.plan
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
```
|
||||
|
||||
Lower priority values have higher precedence. Change this overlay to `priority: 5` if it must win a conflict with the `add-lint` overlay above. It replaces the `review-plan` gate with a non-interactive command.
|
||||
|
||||
### Interaction with Bundles and Updates
|
||||
|
||||
`specify workflow add <local-directory>` installs `workflow.yml` from the local directory into `.specify/workflows/<id>/`.
|
||||
|
||||
When an installed workflow is refreshed or reinstalled, project overlays in `.specify/workflows/overlays/<id>/` are preserved because they live outside the installed workflow directory.
|
||||
|
||||
### Limitations
|
||||
|
||||
- Overlays operate on the step list only. They cannot change workflow metadata (name, description, inputs, `requires`) or expression logic.
|
||||
- Fan-out templates cannot be used as anchors.
|
||||
- An overlay that targets a step id that does not exist in the base workflow will raise a validation error when the workflow is resolved.
|
||||
- Overlays cannot target steps added by other overlays.
|
||||
- Overlays cannot add new inputs or change the input schema of the base workflow.
|
||||
## Update Workflows
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-17T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -2683,10 +2683,10 @@
|
||||
"okf": {
|
||||
"name": "OKF Knowledge Bundle Generator",
|
||||
"id": "okf",
|
||||
"description": "Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository.",
|
||||
"description": "Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository, mining git history for significance and rationale, and resolving open questions with the user.",
|
||||
"author": "Alex Punnen",
|
||||
"version": "0.2.0",
|
||||
"download_url": "https://github.com/alexcpn/speckit_ofk/archive/refs/tags/v0.2.0.zip",
|
||||
"version": "0.3.0",
|
||||
"download_url": "https://github.com/alexcpn/speckit_ofk/archive/refs/tags/v0.3.0.zip",
|
||||
"repository": "https://github.com/alexcpn/speckit_ofk",
|
||||
"homepage": "https://github.com/alexcpn/speckit_ofk",
|
||||
"documentation": "https://github.com/alexcpn/speckit_ofk/blob/main/README.md",
|
||||
@@ -2698,7 +2698,7 @@
|
||||
"speckit_version": ">=0.12.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"commands": 4,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
@@ -2712,7 +2712,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-17T00:00:00Z",
|
||||
"updated_at": "2026-07-17T00:00:00Z"
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
},
|
||||
"onboard": {
|
||||
"name": "Onboard",
|
||||
@@ -4342,6 +4342,40 @@
|
||||
"created_at": "2026-05-20T00:00:00Z",
|
||||
"updated_at": "2026-05-20T00:00:00Z"
|
||||
},
|
||||
"test-coverage-drift-control": {
|
||||
"name": "Test Coverage Drift Control",
|
||||
"id": "test-coverage-drift-control",
|
||||
"description": "Generate incremental coverage drift reports and planned remediation tasks after implementation",
|
||||
"author": "Igor Benicio de Mesquita",
|
||||
"version": "0.3.0",
|
||||
"download_url": "https://github.com/benizzio/spec-kit-test-coverage-drift-control/archive/refs/tags/v0.3.0.zip",
|
||||
"repository": "https://github.com/benizzio/spec-kit-test-coverage-drift-control",
|
||||
"homepage": "https://github.com/benizzio/spec-kit-test-coverage-drift-control",
|
||||
"documentation": "https://github.com/benizzio/spec-kit-test-coverage-drift-control#readme",
|
||||
"changelog": "https://github.com/benizzio/spec-kit-test-coverage-drift-control/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "code",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"analysis",
|
||||
"coverage",
|
||||
"testing",
|
||||
"quality",
|
||||
"maintenance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-21T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
},
|
||||
"time-machine": {
|
||||
"name": "Time Machine",
|
||||
"id": "time-machine",
|
||||
|
||||
@@ -177,11 +177,11 @@
|
||||
"bob": {
|
||||
"id": "bob",
|
||||
"name": "IBM Bob",
|
||||
"version": "1.0.0",
|
||||
"description": "IBM Bob IDE integration",
|
||||
"version": "2.0.0",
|
||||
"description": "IBM Bob 2.0 IDE skills-based integration",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["ide", "ibm"]
|
||||
"tags": ["ide", "ibm", "skills"]
|
||||
},
|
||||
"trae": {
|
||||
"id": "trae",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.13.0"
|
||||
version = "0.13.2"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -48,6 +48,8 @@ packages = ["src/specify_cli"]
|
||||
"workflows/speckit" = "specify_cli/core_pack/workflows/speckit"
|
||||
# Bundled presets (installable via `specify preset add <name>` or `specify init --preset <name>`)
|
||||
"presets/lean" = "specify_cli/core_pack/presets/lean"
|
||||
# Community bundle catalog snapshot (used for offline discovery)
|
||||
"bundles/catalog.community.json" = "specify_cli/core_pack/bundles/catalog.community.json"
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
|
||||
@@ -90,6 +90,19 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MAX_FEATURE_NUMBER=9223372036854775807
|
||||
|
||||
is_feature_number_in_range() {
|
||||
local value="$1"
|
||||
local normalized="${value#"${value%%[!0]*}"}"
|
||||
[ -n "$normalized" ] || normalized=0
|
||||
[ ${#normalized} -lt ${#MAX_FEATURE_NUMBER} ] && return 0
|
||||
[ ${#normalized} -gt ${#MAX_FEATURE_NUMBER} ] && return 1
|
||||
# Equal-length digit strings must be compared without arithmetic overflow.
|
||||
# shellcheck disable=SC2071
|
||||
[[ "$normalized" < "$MAX_FEATURE_NUMBER" || "$normalized" == "$MAX_FEATURE_NUMBER" ]]
|
||||
}
|
||||
|
||||
# Function to get highest number from specs directory
|
||||
get_highest_from_specs() {
|
||||
local specs_dir="$1"
|
||||
@@ -102,9 +115,11 @@ get_highest_from_specs() {
|
||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
if is_feature_number_in_range "$number"; then
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
@@ -119,6 +134,19 @@ clean_branch_name() {
|
||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||
}
|
||||
|
||||
# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote
|
||||
# so the persistence hints match the Python variant exactly (printf %q output
|
||||
# differs between bash versions and from shlex.quote for spaces/metachars).
|
||||
shell_quote() {
|
||||
local value="$1" LC_ALL=C
|
||||
if [[ "$value" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then
|
||||
printf '%s' "$value"
|
||||
else
|
||||
local q="'\"'\"'"
|
||||
printf "'%s'" "${value//\'/$q}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve repository root using common.sh functions which prioritize .specify
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
@@ -202,9 +230,24 @@ if [ "$USE_TIMESTAMP" = true ]; then
|
||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
else
|
||||
if [ -n "$BRANCH_NUMBER" ] && [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
echo "Error: --number must be an unsigned integer, got '$BRANCH_NUMBER'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Bash arithmetic is signed 64-bit; reject digit strings that would wrap.
|
||||
if [ -n "$BRANCH_NUMBER" ] && ! is_feature_number_in_range "$BRANCH_NUMBER"; then
|
||||
echo "Error: --number must be between 0 and $MAX_FEATURE_NUMBER, got '$BRANCH_NUMBER'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine branch number from existing feature directories
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
if [ "$HIGHEST" -eq "$MAX_FEATURE_NUMBER" ]; then
|
||||
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
|
||||
@@ -264,8 +307,8 @@ if [ "$DRY_RUN" != true ]; then
|
||||
_persist_feature_json "$REPO_ROOT" "$FEATURE_DIR"
|
||||
|
||||
# Inform the user how to set feature state in their own shell
|
||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR" >&2
|
||||
printf '# To persist: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" >&2
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" >&2
|
||||
fi
|
||||
|
||||
if $JSON_MODE; then
|
||||
@@ -295,7 +338,7 @@ else
|
||||
echo "SPEC_FILE: $SPEC_FILE"
|
||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR"
|
||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")"
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -29,13 +29,16 @@ function Find-SpecifyRoot {
|
||||
# command against a member project from a monorepo root without cd.
|
||||
#
|
||||
# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root,
|
||||
# or writes an error and exits 1. Strict by design: the path must exist and
|
||||
# or writes an error and exits 1 unless -ReturnNullOnError is set. Strict by
|
||||
# design: the path must exist and
|
||||
# contain .specify/, with no silent fallback. (An empty string is falsy, so the
|
||||
# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.)
|
||||
#
|
||||
# This is the single resolver: bundled extensions inherit it by sourcing core
|
||||
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
|
||||
function Resolve-SpecifyInitDir {
|
||||
param([switch]$ReturnNullOnError)
|
||||
|
||||
$initDir = $env:SPECIFY_INIT_DIR
|
||||
# Normalize: relative paths resolve against the current directory.
|
||||
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
|
||||
@@ -47,6 +50,7 @@ function Resolve-SpecifyInitDir {
|
||||
# "not a Spec Kit project" error below.
|
||||
if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) {
|
||||
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
# Resolve-Path echoes back any trailing separator from the input; trim it so
|
||||
@@ -56,6 +60,7 @@ function Resolve-SpecifyInitDir {
|
||||
$initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path)
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
|
||||
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
return $initRoot
|
||||
@@ -64,9 +69,11 @@ function Resolve-SpecifyInitDir {
|
||||
# Get repository root, prioritizing .specify directory
|
||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
||||
function Get-RepoRoot {
|
||||
param([switch]$ReturnNullOnError)
|
||||
|
||||
# Explicit project override wins (see Resolve-SpecifyInitDir).
|
||||
if ($env:SPECIFY_INIT_DIR) {
|
||||
return (Resolve-SpecifyInitDir)
|
||||
return (Resolve-SpecifyInitDir -ReturnNullOnError:$ReturnNullOnError)
|
||||
}
|
||||
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
@@ -147,10 +154,12 @@ function Get-FeaturePathsEnv {
|
||||
# so pure path resolution never writes .specify/feature.json, which would
|
||||
# dirty the working tree or overwrite a pinned value (issue #3025).
|
||||
param(
|
||||
[switch]$NoPersist
|
||||
[switch]$NoPersist,
|
||||
[switch]$ReturnNullOnError
|
||||
)
|
||||
|
||||
$repoRoot = Get-RepoRoot
|
||||
$repoRoot = Get-RepoRoot -ReturnNullOnError:$ReturnNullOnError
|
||||
if (-not $repoRoot) { return $null }
|
||||
$currentBranch = Get-CurrentBranch
|
||||
|
||||
# Resolve feature directory. Priority:
|
||||
@@ -174,7 +183,8 @@ function Get-FeaturePathsEnv {
|
||||
try {
|
||||
$featureConfig = $featureJsonRaw | ConvertFrom-Json
|
||||
} catch {
|
||||
[Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_")
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
if ($featureConfig.feature_directory) {
|
||||
@@ -185,10 +195,12 @@ function Get-FeaturePathsEnv {
|
||||
}
|
||||
} else {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -334,30 +346,64 @@ function Resolve-Template {
|
||||
if (Test-Path $presetsDir) {
|
||||
$registryFile = Join-Path $presetsDir '.registry'
|
||||
$sortedPresets = @()
|
||||
$registryParsed = $false
|
||||
if (Test-Path $registryFile) {
|
||||
try {
|
||||
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
|
||||
$presets = $registryData.presets
|
||||
if ($presets) {
|
||||
$sortedPresets = $presets.PSObject.Properties |
|
||||
if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
|
||||
throw 'Registry root must be an object'
|
||||
}
|
||||
$presetsProperty = $registryData.PSObject.Properties['presets']
|
||||
if ($presetsProperty) {
|
||||
$presets = $presetsProperty.Value
|
||||
if ($null -eq $presets -or $presets -isnot [PSCustomObject]) {
|
||||
throw 'Registry presets must be an object'
|
||||
}
|
||||
$presetEntries = @($presets.PSObject.Properties)
|
||||
$priorityFor = {
|
||||
param($Entry)
|
||||
if ($Entry.Value -is [PSCustomObject]) {
|
||||
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
|
||||
if ($priorityProperty) { return $priorityProperty.Value }
|
||||
}
|
||||
return 10
|
||||
}
|
||||
if ($presetEntries.Count -gt 1) {
|
||||
$allNumeric = $true
|
||||
$allStrings = $true
|
||||
foreach ($entry in $presetEntries) {
|
||||
$priority = & $priorityFor $entry
|
||||
if ($null -eq $priority -or $priority -isnot [ValueType]) {
|
||||
$allNumeric = $false
|
||||
}
|
||||
if ($null -eq $priority -or $priority -isnot [string]) {
|
||||
$allStrings = $false
|
||||
}
|
||||
}
|
||||
if (-not $allNumeric -and -not $allStrings) {
|
||||
throw 'Registry priorities are not mutually orderable'
|
||||
}
|
||||
}
|
||||
$sortedPresets = $presetEntries |
|
||||
Where-Object { $_.Value -is [PSCustomObject] } |
|
||||
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
|
||||
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
|
||||
Sort-Object { & $priorityFor $_ } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
$registryParsed = $true
|
||||
} catch {
|
||||
# Fallback: alphabetical directory order
|
||||
$sortedPresets = @()
|
||||
$registryParsed = $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($sortedPresets.Count -gt 0) {
|
||||
if ($registryParsed) {
|
||||
foreach ($presetId in $sortedPresets) {
|
||||
$candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
} else {
|
||||
# Fallback: alphabetical directory order
|
||||
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
|
||||
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
|
||||
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ param(
|
||||
[switch]$DryRun,
|
||||
[string]$ShortName,
|
||||
[Parameter()]
|
||||
[long]$Number = 0,
|
||||
[string]$Number = '',
|
||||
[switch]$Timestamp,
|
||||
[switch]$Help,
|
||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||
@@ -142,12 +142,13 @@ if ($ShortName) {
|
||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||
}
|
||||
|
||||
# Warn if -Number and -Timestamp are both specified. Use ContainsKey (not
|
||||
# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's
|
||||
# `[ -n "$BRANCH_NUMBER" ]` check.
|
||||
if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) {
|
||||
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
||||
$Number = 0
|
||||
# Treat an explicit empty string as omitted, matching the bash and Python twins.
|
||||
$hasNumber = $PSBoundParameters.ContainsKey('Number') -and $Number -ne ''
|
||||
|
||||
# Warn if -Number and -Timestamp are both specified.
|
||||
if ($Timestamp -and $hasNumber) {
|
||||
[Console]::Error.WriteLine("[specify] Warning: -Number is ignored when -Timestamp is used")
|
||||
$Number = ''
|
||||
}
|
||||
|
||||
# Determine branch prefix
|
||||
@@ -158,11 +159,23 @@ if ($Timestamp) {
|
||||
# Determine branch number from existing feature directories. Auto-detect only
|
||||
# when -Number was not supplied; an explicit value (including 0) is honored,
|
||||
# matching the bash twin's `[ -z "$BRANCH_NUMBER" ]` check.
|
||||
if (-not $PSBoundParameters.ContainsKey('Number')) {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
[long]$resolvedNumber = 0
|
||||
if (-not $hasNumber) {
|
||||
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
|
||||
if ($highestNumber -eq [long]::MaxValue) {
|
||||
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
|
||||
exit 1
|
||||
}
|
||||
$resolvedNumber = $highestNumber + 1
|
||||
} elseif ($Number -notmatch '^[0-9]+$') {
|
||||
Write-Error "Error: -Number must be an unsigned integer, got '$Number'"
|
||||
exit 1
|
||||
} elseif (-not [long]::TryParse($Number, [ref]$resolvedNumber)) {
|
||||
Write-Error "Error: -Number must be between 0 and $([long]::MaxValue), got '$Number'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $Number)
|
||||
$featureNum = ('{0:000}' -f $resolvedNumber)
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
}
|
||||
|
||||
@@ -183,9 +196,9 @@ if ($branchName.Length -gt $maxBranchLength) {
|
||||
$originalBranchName = $branchName
|
||||
$branchName = "$featureNum-$truncatedSuffix"
|
||||
|
||||
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
||||
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
||||
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
||||
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
|
||||
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
|
||||
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")
|
||||
}
|
||||
|
||||
$featureDir = Join-Path $specsDir $branchName
|
||||
@@ -225,6 +238,13 @@ if (-not $DryRun) {
|
||||
# Set environment variables for the current session
|
||||
$env:SPECIFY_FEATURE = $branchName
|
||||
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
|
||||
|
||||
$quotedBranchName = "'" + $branchName.Replace("'", "''") + "'"
|
||||
$quotedFeatureDir = "'" + $featureDir.Replace("'", "''") + "'"
|
||||
$featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName
|
||||
$directoryAssignment = '$env:SPECIFY_FEATURE_DIRECTORY = ' + $quotedFeatureDir
|
||||
[Console]::Error.WriteLine("# To persist: $featureAssignment")
|
||||
[Console]::Error.WriteLine("# $directoryAssignment")
|
||||
}
|
||||
|
||||
if ($Json) {
|
||||
@@ -242,7 +262,7 @@ if ($Json) {
|
||||
Write-Output "SPEC_FILE: $specFile"
|
||||
Write-Output "FEATURE_NUM: $featureNum"
|
||||
if (-not $DryRun) {
|
||||
Write-Output "SPECIFY_FEATURE set to: $branchName"
|
||||
Write-Output "SPECIFY_FEATURE_DIRECTORY set to: $featureDir"
|
||||
Write-Output "# To persist in your shell: $featureAssignment"
|
||||
Write-Output "# $directoryAssignment"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$Help
|
||||
[switch]$Help,
|
||||
# Capture extra positional arguments to match Bash/Python behavior.
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$RemainingArgs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -21,7 +24,11 @@ if ($Help) {
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get all paths and variables from common functions
|
||||
$paths = Get-FeaturePathsEnv
|
||||
$paths = Get-FeaturePathsEnv -ReturnNullOnError
|
||||
if (-not $paths) {
|
||||
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Ensure the feature directory exists
|
||||
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||
|
||||
@@ -3,21 +3,34 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$Help
|
||||
[switch]$Help,
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$RemainingArgs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Help wins over unknown-argument validation to match the Bash/Python
|
||||
# variants, which stop at --help and exit 0.
|
||||
if ($Help) {
|
||||
Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($RemainingArgs.Count -gt 0) {
|
||||
[Console]::Error.WriteLine("ERROR: Unknown option '$($RemainingArgs[0])'")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Source common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get feature paths
|
||||
$paths = Get-FeaturePathsEnv
|
||||
$paths = Get-FeaturePathsEnv -ReturnNullOnError
|
||||
if (-not $paths) {
|
||||
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||
@@ -45,8 +58,8 @@ if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
|
||||
# Resolve tasks template through override stack
|
||||
$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
|
||||
if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
|
||||
$expectedCoreTemplate = Join-Path $paths.REPO_ROOT '.specify/templates/tasks-template.md'
|
||||
[Console]::Error.WriteLine("ERROR: Tasks template not found for repository root: $($paths.REPO_ROOT)`nTemplate resolution order: overrides -> presets -> extensions -> core.`nExpected shared/core template location: $expectedCoreTemplate`nTo continue, verify whether 'tasks-template.md' is available in '.specify/templates/overrides/', preset templates, extension templates, or restore the shared/core templates (for example by re-running 'specify init') so that '.specify/templates/tasks-template.md' exists.")
|
||||
[Console]::Error.WriteLine("ERROR: Could not resolve required tasks-template from the template override stack for $($paths.REPO_ROOT)")
|
||||
[Console]::Error.WriteLine("Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template.")
|
||||
exit 1
|
||||
}
|
||||
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
|
||||
|
||||
@@ -84,7 +84,7 @@ def read_feature_json_feature_directory(repo_root: Path) -> str:
|
||||
return ""
|
||||
try:
|
||||
data = json.loads(feature_json.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return ""
|
||||
value = data.get("feature_directory") if isinstance(data, dict) else None
|
||||
return value if isinstance(value, str) else ""
|
||||
@@ -95,16 +95,17 @@ def _json_dump(data: dict[str, str]) -> str:
|
||||
|
||||
|
||||
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
|
||||
# Strip the repo root prefix lexically (no resolve()) to mirror the
|
||||
# Bash/PowerShell helpers: with a symlinked <repo>/specs, resolve() would
|
||||
# escape the repo and persist a machine-specific absolute path instead of
|
||||
# the relative "specs/NNN-name" the other variants store.
|
||||
value = feature_dir_value
|
||||
try:
|
||||
relative = Path(value)
|
||||
if relative.is_absolute():
|
||||
try:
|
||||
value = relative.resolve().relative_to(repo_root.resolve()).as_posix()
|
||||
except ValueError:
|
||||
value = str(relative)
|
||||
except OSError:
|
||||
pass
|
||||
relative = Path(value)
|
||||
if relative.is_absolute():
|
||||
try:
|
||||
value = relative.relative_to(repo_root).as_posix()
|
||||
except ValueError:
|
||||
value = str(relative)
|
||||
|
||||
current = read_feature_json_feature_directory(repo_root)
|
||||
if current == value:
|
||||
@@ -112,9 +113,8 @@ def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
|
||||
|
||||
specify_dir = repo_root / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "feature.json").write_text(
|
||||
_json_dump({"feature_directory": value}),
|
||||
encoding="utf-8",
|
||||
(specify_dir / "feature.json").write_bytes(
|
||||
_json_dump({"feature_directory": value}).encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
@@ -182,6 +182,78 @@ def get_feature_paths(
|
||||
)
|
||||
|
||||
|
||||
def _sorted_preset_ids(presets_dir: Path) -> list[str]:
|
||||
registry = presets_dir / ".registry"
|
||||
if registry.is_file():
|
||||
# Mirrors bash: any failure while reading or sorting the registry
|
||||
# (invalid JSON, non-dict shapes, unorderable priority values) falls
|
||||
# back to the directory scan below.
|
||||
try:
|
||||
data = json.loads(registry.read_text(encoding="utf-8"))
|
||||
presets = data.get("presets", {})
|
||||
return [
|
||||
pid
|
||||
for pid, meta in sorted(
|
||||
presets.items(),
|
||||
key=lambda kv: kv[1].get("priority", 10)
|
||||
if isinstance(kv[1], dict)
|
||||
else 10,
|
||||
)
|
||||
if isinstance(meta, dict) and meta.get("enabled", True) is not False
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return sorted(
|
||||
p.name
|
||||
for p in presets_dir.iterdir()
|
||||
if p.is_dir() and not p.name.startswith(".")
|
||||
)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def resolve_template(template_name: str, repo_root: Path) -> Path | None:
|
||||
"""Resolve a template name to a file path using the priority stack.
|
||||
|
||||
Order (mirrors resolve_template in scripts/bash/common.sh):
|
||||
1. .specify/templates/overrides/
|
||||
2. .specify/presets/<preset-id>/templates/ (sorted by .registry priority)
|
||||
3. .specify/extensions/<ext-id>/templates/ (hidden directories skipped)
|
||||
4. .specify/templates/ (core)
|
||||
"""
|
||||
base = repo_root / ".specify" / "templates"
|
||||
|
||||
override = base / "overrides" / f"{template_name}.md"
|
||||
if override.is_file():
|
||||
return override
|
||||
|
||||
presets_dir = repo_root / ".specify" / "presets"
|
||||
if presets_dir.is_dir():
|
||||
for preset_id in _sorted_preset_ids(presets_dir):
|
||||
candidate = presets_dir / preset_id / "templates" / f"{template_name}.md"
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
|
||||
ext_dir = repo_root / ".specify" / "extensions"
|
||||
if ext_dir.is_dir():
|
||||
try:
|
||||
extensions = sorted(p for p in ext_dir.iterdir() if p.is_dir())
|
||||
except OSError:
|
||||
extensions = []
|
||||
for ext in extensions:
|
||||
if ext.name.startswith("."):
|
||||
continue
|
||||
candidate = ext / "templates" / f"{template_name}.md"
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
|
||||
core = base / f"{template_name}.md"
|
||||
if core.is_file():
|
||||
return core
|
||||
return None
|
||||
|
||||
|
||||
def get_invoke_separator(repo_root: Path) -> str:
|
||||
integration_json = repo_root / ".specify" / "integration.json"
|
||||
if not integration_json.is_file():
|
||||
|
||||
355
scripts/python/create_new_feature.py
Normal file
355
scripts/python/create_new_feature.py
Normal file
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a new feature directory and spec file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import get_repo_root, persist_feature_json, resolve_template
|
||||
except ImportError: # pragma: no cover - direct execution from unusual cwd
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import get_repo_root, persist_feature_json, resolve_template
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
_STOP_WORDS = frozenset(
|
||||
"""
|
||||
i a an the to for of in on at by with from is are was were be been being
|
||||
have has had do does did will would should could can may might must shall
|
||||
this that these those my your our their want need add get set
|
||||
""".split()
|
||||
)
|
||||
|
||||
_MAX_BRANCH_LENGTH = 244
|
||||
_MAX_FEATURE_NUMBER = 2**63 - 1
|
||||
|
||||
|
||||
def _int64_from_digits(value: str) -> int | None:
|
||||
normalized = value.lstrip("0") or "0"
|
||||
maximum = str(_MAX_FEATURE_NUMBER)
|
||||
if len(normalized) > len(maximum) or (
|
||||
len(normalized) == len(maximum) and normalized > maximum
|
||||
):
|
||||
return None
|
||||
return int(normalized, 10)
|
||||
|
||||
|
||||
def _persistence_assignments(
|
||||
branch_name: str, feature_dir: str, *, powershell: bool
|
||||
) -> tuple[str, str]:
|
||||
if powershell:
|
||||
quoted_branch = "'" + branch_name.replace("'", "''") + "'"
|
||||
quoted_dir = "'" + feature_dir.replace("'", "''") + "'"
|
||||
return (
|
||||
f"$env:SPECIFY_FEATURE = {quoted_branch}",
|
||||
f"$env:SPECIFY_FEATURE_DIRECTORY = {quoted_dir}",
|
||||
)
|
||||
return (
|
||||
f"export SPECIFY_FEATURE={shlex.quote(branch_name)}",
|
||||
f"export SPECIFY_FEATURE_DIRECTORY={shlex.quote(feature_dir)}",
|
||||
)
|
||||
|
||||
|
||||
def _usage(argv0: str) -> str:
|
||||
return (
|
||||
f"Usage: {argv0} [--json] [--dry-run] [--allow-existing-branch] "
|
||||
"[--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
||||
)
|
||||
|
||||
|
||||
def _help_text(argv0: str) -> str:
|
||||
return f"""{_usage(argv0)}
|
||||
|
||||
Options:
|
||||
--json Output in JSON format
|
||||
--dry-run Compute feature name and paths without creating directories or files
|
||||
--allow-existing-branch Reuse an existing feature directory if it already exists
|
||||
--short-name <name> Provide a custom short name (2-4 words) for the feature
|
||||
--number N Specify branch number manually (overrides auto-detection)
|
||||
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
|
||||
--help, -h Show this help message
|
||||
|
||||
Examples:
|
||||
{argv0} 'Add user authentication system' --short-name 'user-auth'
|
||||
{argv0} 'Implement OAuth2 integration for API' --number 5
|
||||
{argv0} --timestamp --short-name 'user-auth' 'Add user authentication'
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Args:
|
||||
json_mode: bool = False
|
||||
dry_run: bool = False
|
||||
allow_existing: bool = False
|
||||
short_name: str = ""
|
||||
branch_number: str = ""
|
||||
use_timestamp: bool = False
|
||||
description: str = ""
|
||||
|
||||
|
||||
def _parse_args(argv: list[str], argv0: str) -> Args:
|
||||
json_mode = False
|
||||
dry_run = False
|
||||
allow_existing = False
|
||||
short_name = ""
|
||||
branch_number = ""
|
||||
use_timestamp = False
|
||||
rest: list[str] = []
|
||||
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if arg == "--json":
|
||||
json_mode = True
|
||||
elif arg == "--dry-run":
|
||||
dry_run = True
|
||||
elif arg == "--allow-existing-branch":
|
||||
allow_existing = True
|
||||
elif arg in {"--short-name", "--number"}:
|
||||
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
|
||||
print(f"Error: {arg} requires a value", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
i += 1
|
||||
if arg == "--short-name":
|
||||
short_name = argv[i]
|
||||
else:
|
||||
branch_number = argv[i]
|
||||
elif arg == "--timestamp":
|
||||
use_timestamp = True
|
||||
elif arg in {"--help", "-h"}:
|
||||
sys.stdout.write(_help_text(argv0))
|
||||
raise SystemExit(0)
|
||||
else:
|
||||
rest.append(arg)
|
||||
i += 1
|
||||
|
||||
description = " ".join(rest).strip()
|
||||
if not description:
|
||||
if rest:
|
||||
print(
|
||||
"Error: Feature description cannot be empty or contain only whitespace",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(_usage(argv0), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
return Args(
|
||||
json_mode=json_mode,
|
||||
dry_run=dry_run,
|
||||
allow_existing=allow_existing,
|
||||
short_name=short_name,
|
||||
branch_number=branch_number,
|
||||
use_timestamp=use_timestamp,
|
||||
description=description,
|
||||
)
|
||||
|
||||
|
||||
def _clean_branch_name(name: str) -> str:
|
||||
cleaned = re.sub(r"[^a-z0-9]", "-", name.lower())
|
||||
cleaned = re.sub(r"-+", "-", cleaned)
|
||||
return cleaned.strip("-")
|
||||
|
||||
|
||||
def _generate_branch_name(description: str) -> str:
|
||||
clean = re.sub(r"[^a-z0-9]", " ", description.lower())
|
||||
meaningful: list[str] = []
|
||||
for word in clean.split():
|
||||
if word in _STOP_WORDS:
|
||||
continue
|
||||
if len(word) >= 3:
|
||||
meaningful.append(word)
|
||||
# Keep short words that appear as an uppercase acronym in the original,
|
||||
# mirroring the bash twin's case-sensitive `grep -qw` check.
|
||||
elif re.search(
|
||||
rf"(?<![0-9A-Za-z_]){re.escape(word.upper())}(?![0-9A-Za-z_])",
|
||||
description,
|
||||
):
|
||||
meaningful.append(word)
|
||||
|
||||
if meaningful:
|
||||
max_words = 4 if len(meaningful) == 4 else 3
|
||||
return "-".join(meaningful[:max_words])
|
||||
|
||||
cleaned = _clean_branch_name(description)
|
||||
return "-".join([part for part in cleaned.split("-") if part][:3])
|
||||
|
||||
|
||||
def _get_highest_from_specs(specs_dir: Path) -> int:
|
||||
highest = 0
|
||||
if not specs_dir.is_dir():
|
||||
return highest
|
||||
for entry in specs_dir.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
name = entry.name
|
||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||
if re.match(r"^[0-9]{3,}-", name) and not re.match(
|
||||
r"^[0-9]{8}-[0-9]{6}-", name
|
||||
):
|
||||
number = _int64_from_digits(re.match(r"^[0-9]+", name).group())
|
||||
if number is not None:
|
||||
highest = max(highest, number)
|
||||
return highest
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv0 = sys.argv[0]
|
||||
args = _parse_args(list(argv if argv is not None else sys.argv[1:]), argv0)
|
||||
|
||||
repo_root = get_repo_root(Path(__file__))
|
||||
specs_dir = repo_root / "specs"
|
||||
if not args.dry_run:
|
||||
specs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.short_name:
|
||||
branch_suffix = _clean_branch_name(args.short_name)
|
||||
else:
|
||||
branch_suffix = _generate_branch_name(args.description)
|
||||
|
||||
branch_number = args.branch_number
|
||||
if args.use_timestamp and branch_number:
|
||||
print(
|
||||
"[specify] Warning: --number is ignored when --timestamp is used",
|
||||
file=sys.stderr,
|
||||
)
|
||||
branch_number = ""
|
||||
|
||||
if args.use_timestamp:
|
||||
feature_num = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
else:
|
||||
if branch_number:
|
||||
# Mirrors bash: $((10#$BRANCH_NUMBER)) only accepts unsigned
|
||||
# decimal digits, rejecting signs, whitespace, and other
|
||||
# characters that int() would otherwise tolerate.
|
||||
if not re.fullmatch(r"[0-9]+", branch_number):
|
||||
print(
|
||||
"Error: --number must be an unsigned integer, "
|
||||
f"got '{branch_number}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
number = _int64_from_digits(branch_number)
|
||||
if number is None:
|
||||
print(
|
||||
"Error: --number must be between 0 and "
|
||||
f"{_MAX_FEATURE_NUMBER}, got '{branch_number}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
else:
|
||||
number = _get_highest_from_specs(specs_dir) + 1
|
||||
if number > _MAX_FEATURE_NUMBER:
|
||||
rejected_number = branch_number or str(number)
|
||||
number_label = "--number" if branch_number else "feature number"
|
||||
print(
|
||||
f"Error: {number_label} must be between 0 and "
|
||||
f"{_MAX_FEATURE_NUMBER}, got '{rejected_number}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
feature_num = f"{number:03d}"
|
||||
|
||||
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
|
||||
if max_suffix_length <= 0:
|
||||
print("Error: feature number is too long for a branch name", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
branch_name = f"{feature_num}-{branch_suffix}"
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names.
|
||||
if len(branch_name) > _MAX_BRANCH_LENGTH:
|
||||
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
|
||||
original_branch_name = branch_name
|
||||
branch_name = f"{feature_num}-{truncated_suffix}"
|
||||
print(
|
||||
"[specify] Warning: Branch name exceeded GitHub's 244-byte limit",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
f"[specify] Original: {original_branch_name} "
|
||||
f"({len(original_branch_name)} bytes)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
f"[specify] Truncated to: {branch_name} ({len(branch_name)} bytes)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
feature_dir = specs_dir / branch_name
|
||||
spec_file = feature_dir / "spec.md"
|
||||
|
||||
if not args.dry_run:
|
||||
if feature_dir.is_dir() and not args.allow_existing:
|
||||
if args.use_timestamp:
|
||||
print(
|
||||
f"Error: Feature directory '{feature_dir}' already exists. "
|
||||
"Rerun to get a new timestamp or use a different --short-name.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"Error: Feature directory '{feature_dir}' already exists. "
|
||||
"Please use a different feature name or specify a different "
|
||||
"number with --number.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
feature_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not spec_file.is_file():
|
||||
template = resolve_template("spec-template", repo_root)
|
||||
if template is not None and template.is_file():
|
||||
shutil.copy(template, spec_file)
|
||||
else:
|
||||
print(
|
||||
"Warning: Spec template not found; created empty spec file",
|
||||
file=sys.stderr,
|
||||
)
|
||||
spec_file.touch()
|
||||
|
||||
# Persist to .specify/feature.json so downstream commands can find the feature.
|
||||
persist_feature_json(repo_root, f"specs/{branch_name}")
|
||||
|
||||
# Inform the user how to set feature state in their own shell.
|
||||
feature_assignment, directory_assignment = _persistence_assignments(
|
||||
branch_name,
|
||||
str(feature_dir),
|
||||
powershell=sys.platform == "win32",
|
||||
)
|
||||
print(f"# To persist: {feature_assignment}", file=sys.stderr)
|
||||
print(f"# {directory_assignment}", file=sys.stderr)
|
||||
|
||||
if args.json_mode:
|
||||
payload: dict[str, object] = {
|
||||
"BRANCH_NAME": branch_name,
|
||||
"SPEC_FILE": str(spec_file),
|
||||
"FEATURE_NUM": feature_num,
|
||||
}
|
||||
if args.dry_run:
|
||||
payload["DRY_RUN"] = True
|
||||
sys.stdout.write(_json_line(payload))
|
||||
else:
|
||||
print(f"BRANCH_NAME: {branch_name}")
|
||||
print(f"SPEC_FILE: {spec_file}")
|
||||
print(f"FEATURE_NUM: {feature_num}")
|
||||
if not args.dry_run:
|
||||
print(f"# To persist in your shell: {feature_assignment}")
|
||||
print(f"# {directory_assignment}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
86
scripts/python/setup_plan.py
Normal file
86
scripts/python/setup_plan.py
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Setup implementation plan for a feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import get_feature_paths, resolve_template
|
||||
except ImportError: # pragma: no cover - direct execution from unusual cwd
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import get_feature_paths, resolve_template
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
def _help_text(argv0: str) -> str:
|
||||
return f"""Usage: {argv0} [--json]
|
||||
--json Output results in JSON format
|
||||
--help Show this help message
|
||||
"""
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = list(argv if argv is not None else sys.argv[1:])
|
||||
json_mode = False
|
||||
for arg in args:
|
||||
if arg == "--json":
|
||||
json_mode = True
|
||||
elif arg in {"--help", "-h"}:
|
||||
sys.stdout.write(_help_text(sys.argv[0]))
|
||||
return 0
|
||||
# Other arguments are accepted and silently ignored, matching setup-plan.sh.
|
||||
|
||||
try:
|
||||
paths = get_feature_paths(script_file=Path(__file__))
|
||||
except SystemExit as exc:
|
||||
if exc.code == 0:
|
||||
return 0
|
||||
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
|
||||
return int(exc.code) if isinstance(exc.code, int) else 1
|
||||
|
||||
paths.feature_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Status messages go to stderr in JSON mode so stdout stays pure JSON.
|
||||
status_stream = sys.stderr if json_mode else sys.stdout
|
||||
if paths.impl_plan.is_file():
|
||||
print(
|
||||
f"Plan already exists at {paths.impl_plan}, skipping template copy",
|
||||
file=status_stream,
|
||||
)
|
||||
else:
|
||||
template = resolve_template("plan-template", paths.repo_root)
|
||||
if template is not None and template.is_file():
|
||||
shutil.copy(template, paths.impl_plan)
|
||||
print(f"Copied plan template to {paths.impl_plan}", file=status_stream)
|
||||
else:
|
||||
print("Warning: Plan template not found", file=status_stream)
|
||||
paths.impl_plan.touch()
|
||||
|
||||
if json_mode:
|
||||
sys.stdout.write(
|
||||
_json_line(
|
||||
{
|
||||
"FEATURE_SPEC": str(paths.feature_spec),
|
||||
"IMPL_PLAN": str(paths.impl_plan),
|
||||
"SPECS_DIR": str(paths.feature_dir),
|
||||
"BRANCH": paths.current_branch,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(f"FEATURE_SPEC: {paths.feature_spec}")
|
||||
print(f"IMPL_PLAN: {paths.impl_plan}")
|
||||
print(f"SPECS_DIR: {paths.feature_dir}")
|
||||
print(f"BRANCH: {paths.current_branch}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
145
scripts/python/setup_tasks.py
Normal file
145
scripts/python/setup_tasks.py
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check tasks prerequisites and resolve the tasks template."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import (
|
||||
FeaturePaths,
|
||||
format_speckit_command,
|
||||
get_feature_paths,
|
||||
resolve_template,
|
||||
)
|
||||
except ImportError: # pragma: no cover - direct execution from unusual cwd
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import (
|
||||
FeaturePaths,
|
||||
format_speckit_command,
|
||||
get_feature_paths,
|
||||
resolve_template,
|
||||
)
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
def _help_text(argv0: str) -> str:
|
||||
return f"""Usage: {argv0} [--json]
|
||||
--json Output results in JSON format
|
||||
--help Show this help message
|
||||
"""
|
||||
|
||||
|
||||
def _dir_has_entries(path: Path) -> bool:
|
||||
try:
|
||||
return path.is_dir() and any(path.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _available_docs(paths: FeaturePaths) -> list[str]:
|
||||
docs: list[str] = []
|
||||
if paths.research.is_file():
|
||||
docs.append("research.md")
|
||||
if paths.data_model.is_file():
|
||||
docs.append("data-model.md")
|
||||
if _dir_has_entries(paths.contracts_dir):
|
||||
docs.append("contracts/")
|
||||
if paths.quickstart.is_file():
|
||||
docs.append("quickstart.md")
|
||||
return docs
|
||||
|
||||
|
||||
def _check_file(path: Path, description: str) -> None:
|
||||
marker = "✓" if path.is_file() else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _check_dir(path: Path, description: str) -> None:
|
||||
marker = "✓" if _dir_has_entries(path) else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
json_mode = False
|
||||
for arg in list(argv if argv is not None else sys.argv[1:]):
|
||||
if arg == "--json":
|
||||
json_mode = True
|
||||
elif arg in {"--help", "-h"}:
|
||||
sys.stdout.write(_help_text(sys.argv[0]))
|
||||
return 0
|
||||
else:
|
||||
print(f"ERROR: Unknown option '{arg}'", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
paths = get_feature_paths(script_file=Path(__file__))
|
||||
except SystemExit as exc:
|
||||
if exc.code == 0:
|
||||
return 0
|
||||
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
|
||||
return int(exc.code) if isinstance(exc.code, int) else 1
|
||||
|
||||
if not paths.impl_plan.is_file():
|
||||
print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if not paths.feature_spec.is_file():
|
||||
print(f"ERROR: spec.md not found in {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
docs = _available_docs(paths)
|
||||
|
||||
tasks_template = resolve_template("tasks-template", paths.repo_root)
|
||||
if tasks_template is None or not tasks_template.is_file():
|
||||
print(
|
||||
"ERROR: Could not resolve required tasks-template from the template "
|
||||
f"override stack for {paths.repo_root}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"Template 'tasks-template' was not found in any supported location "
|
||||
"(overrides, presets, extensions, or shared core). Add an override at "
|
||||
".specify/templates/overrides/tasks-template.md, or run 'specify init' "
|
||||
"/ reinstall shared infra to restore the core "
|
||||
".specify/templates/tasks-template.md template.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if json_mode:
|
||||
sys.stdout.write(
|
||||
_json_line(
|
||||
{
|
||||
"FEATURE_DIR": str(paths.feature_dir),
|
||||
"AVAILABLE_DOCS": docs,
|
||||
"TASKS_TEMPLATE": str(tasks_template),
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(f"FEATURE_DIR: {paths.feature_dir}")
|
||||
print(f"TASKS_TEMPLATE: {tasks_template}")
|
||||
print("AVAILABLE_DOCS:")
|
||||
_check_file(paths.research, "research.md")
|
||||
_check_file(paths.data_model, "data-model.md")
|
||||
_check_dir(paths.contracts_dir, "contracts/")
|
||||
_check_file(paths.quickstart, "quickstart.md")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -140,10 +140,9 @@ def _install_shared_infra(
|
||||
"""Install shared infrastructure files into *project_path*.
|
||||
|
||||
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
|
||||
the bundled core_pack or source checkout, where ``<variant>`` is
|
||||
``bash`` when *script_type* is ``"sh"``, ``python`` when it is ``"py"``,
|
||||
and ``powershell`` when it is ``"ps"``. Tracks all installed files in
|
||||
``speckit.manifest.json``.
|
||||
the bundled core_pack or source checkout. ``sh`` installs Bash, ``ps``
|
||||
installs PowerShell, and ``py`` installs Python plus the platform shell
|
||||
fallback. Tracks all installed files in ``speckit.manifest.json``.
|
||||
|
||||
Shared scripts and page templates are processed to resolve
|
||||
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*
|
||||
|
||||
@@ -18,6 +18,7 @@ ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "grok", "trae", "zed"}
|
||||
CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
|
||||
{
|
||||
"agy",
|
||||
"bob",
|
||||
"claude",
|
||||
"copilot",
|
||||
"cursor-agent",
|
||||
|
||||
@@ -7,7 +7,6 @@ command files into agent-specific directories in the correct format.
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
@@ -114,13 +113,24 @@ class CommandRegistrar:
|
||||
if not content.startswith("---"):
|
||||
return {}, content
|
||||
|
||||
# Find second ---
|
||||
end_marker = content.find("---", 3)
|
||||
if end_marker == -1:
|
||||
# The closing delimiter is a line that is exactly ``---`` (a YAML
|
||||
# document separator), not any ``---`` substring. Scanning with
|
||||
# ``content.find("---", 3)`` stops at the first ``---`` *anywhere* —
|
||||
# including one embedded in a frontmatter value (e.g. a description like
|
||||
# "Separate sections with ---") or inside an indented literal block —
|
||||
# which truncates the frontmatter and spills the remainder into the
|
||||
# body. Match on line boundaries instead, mirroring the line-anchored
|
||||
# scan in ``VibeIntegration._inject_frontmatter_flag``.
|
||||
lines = content.splitlines(keepends=True)
|
||||
end_line = next(
|
||||
(i for i in range(1, len(lines)) if lines[i].rstrip() == "---"),
|
||||
None,
|
||||
)
|
||||
if end_line is None:
|
||||
return {}, content
|
||||
|
||||
frontmatter_str = content[3:end_marker].strip()
|
||||
body = content[end_marker + 3 :].strip()
|
||||
frontmatter_str = "".join(lines[1:end_line]).strip()
|
||||
body = "".join(lines[end_line + 1 :]).strip()
|
||||
|
||||
try:
|
||||
frontmatter = yaml.safe_load(frontmatter_str) or {}
|
||||
@@ -475,26 +485,19 @@ class CommandRegistrar:
|
||||
init_opts = {}
|
||||
|
||||
script_variant = init_opts.get("script")
|
||||
if script_variant not in {"sh", "ps"}:
|
||||
fallback_order = []
|
||||
default_variant = (
|
||||
"ps" if platform.system().lower().startswith("win") else "sh"
|
||||
if scripts:
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
|
||||
script_variant = IntegrationBase.select_script_variant(
|
||||
script_variant, scripts
|
||||
)
|
||||
secondary_variant = "sh" if default_variant == "ps" else "ps"
|
||||
|
||||
if default_variant in scripts:
|
||||
fallback_order.append(default_variant)
|
||||
if secondary_variant in scripts:
|
||||
fallback_order.append(secondary_variant)
|
||||
|
||||
for key in scripts:
|
||||
if key not in fallback_order:
|
||||
fallback_order.append(key)
|
||||
|
||||
script_variant = fallback_order[0] if fallback_order else None
|
||||
|
||||
script_command = scripts.get(script_variant) if script_variant else None
|
||||
if script_command:
|
||||
if script_variant == "py":
|
||||
script_command = IntegrationBase.build_python_invocation(
|
||||
script_command, project_root
|
||||
)
|
||||
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
|
||||
body = body.replace("{SCRIPT}", script_command)
|
||||
|
||||
@@ -637,6 +640,37 @@ class CommandRegistrar:
|
||||
is_cline_ext = agent_name == "cline" and source_id != "core"
|
||||
source_root = source_dir.resolve()
|
||||
|
||||
# Resolve the command-reference separator for the file THIS registrar
|
||||
# is about to write. The separator must match the *output layout* the
|
||||
# registrar produces for this agent — not the project's persisted
|
||||
# ``ai_skills`` flag, and not unrelated sibling directories on disk. A
|
||||
# skill scaffold ("/SKILL.md") uses the skills separator; any
|
||||
# command-layout output (".md", ".agent.md", ".toml", …) uses the
|
||||
# command separator.
|
||||
#
|
||||
# This holds for the *active* agent too. Dual-layout agents (Bob,
|
||||
# Copilot) write their skills via their own setup()/skills path, so
|
||||
# ``register_commands`` only ever emits their command-layout files.
|
||||
# Deriving the separator from ``ai_skills`` would render such a
|
||||
# ``.bob/commands/*.md`` (or ``.github/agents/*.agent.md``) file with
|
||||
# ``/speckit-*`` whenever that agent is active in skills mode — even
|
||||
# though a command-layout file must use ``/speckit.*``. Deriving it
|
||||
# from the agent's static output config avoids that mismatch and stays
|
||||
# correct when a stale ``.bob/skills`` directory coexists with
|
||||
# ``.bob/commands``.
|
||||
_sep = agent_config.get("invoke_separator", ".")
|
||||
try:
|
||||
from specify_cli.integrations import get_integration # noqa: PLC0415
|
||||
|
||||
_integ = get_integration(agent_name)
|
||||
if _integ is not None:
|
||||
registrar_writes_skills = (
|
||||
agent_config.get("extension") == "/SKILL.md"
|
||||
)
|
||||
_sep = _integ.invoke_separator_for_mode(registrar_writes_skills)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for cmd_info in commands:
|
||||
cmd_name = cmd_info["name"]
|
||||
aliases = cmd_info.get("aliases", [])
|
||||
@@ -709,13 +743,18 @@ class CommandRegistrar:
|
||||
)
|
||||
|
||||
# Resolve __SPECKIT_COMMAND_*__ tokens using the agent's invoke separator.
|
||||
# The separator is sourced from agent_config (populated by _build_agent_configs,
|
||||
# which propagates each integration's invoke_separator class attribute).
|
||||
# For dual-layout agents (e.g. Bob) the separator differs between the
|
||||
# skills and command layouts, so a single static AGENT_CONFIGS value is
|
||||
# insufficient. ``_sep`` (resolved above) is derived from the *output
|
||||
# layout* this registrar writes — a "/SKILL.md" scaffold uses the skills
|
||||
# separator, any command-layout file uses the command separator — not
|
||||
# the project's persisted ai_skills state. Single-layout agents fall back
|
||||
# to the static AGENT_CONFIGS value unchanged (invoke_separator_for_mode
|
||||
# default).
|
||||
# Deferred import of IntegrationBase avoids a circular import at module load
|
||||
# (base.py itself imports CommandRegistrar lazily).
|
||||
from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415
|
||||
|
||||
_sep = agent_config.get("invoke_separator", ".")
|
||||
body = IntegrationBase.resolve_command_refs(body, _sep)
|
||||
|
||||
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
|
||||
|
||||
@@ -43,7 +43,7 @@ class Scope(str, Enum):
|
||||
BUILTIN_DEFAULT_STACK: tuple[dict[str, Any], ...] = (
|
||||
{"id": "default", "url": "builtin://default", "priority": 1,
|
||||
"install_policy": InstallPolicy.INSTALL_ALLOWED.value},
|
||||
{"id": "community", "url": "builtin://community", "priority": 2,
|
||||
{"id": "community", "url": "builtin://community", "priority": 20,
|
||||
"install_policy": InstallPolicy.DISCOVERY_ONLY.value},
|
||||
)
|
||||
|
||||
|
||||
@@ -15,25 +15,26 @@ from pathlib import Path
|
||||
from urllib.parse import ParseResult, urlparse
|
||||
from urllib.request import url2pathname
|
||||
|
||||
from ..._assets import _locate_core_pack, _repo_root
|
||||
from .. import BundlerError
|
||||
from ..lib.yamlio import loads_json
|
||||
from ..models.catalog import CatalogSource
|
||||
from ..models.manifest import ComponentRef
|
||||
|
||||
# Built-in catalog payloads ship empty by default; a host distribution can
|
||||
# replace these with curated content. Keeping them here makes ``search``/``info``
|
||||
# work fully offline against the default stack.
|
||||
COMMUNITY_CATALOG_URL = (
|
||||
"https://raw.githubusercontent.com/github/spec-kit/main/"
|
||||
"bundles/catalog.community.json"
|
||||
)
|
||||
|
||||
# The default catalog is reserved for first-party bundles. The community
|
||||
# catalog is loaded from the repository online and from the packaged snapshot
|
||||
# offline so discovery remains useful without network access.
|
||||
_BUILTIN_CATALOGS: dict[str, dict] = {
|
||||
"builtin://default": {
|
||||
"schema_version": "1.0",
|
||||
"catalog_url": "builtin://default",
|
||||
"bundles": {},
|
||||
},
|
||||
"builtin://community": {
|
||||
"schema_version": "1.0",
|
||||
"catalog_url": "builtin://community",
|
||||
"bundles": {},
|
||||
},
|
||||
}
|
||||
|
||||
HTTP_TIMEOUT_SECONDS = 10
|
||||
@@ -95,6 +96,18 @@ def _validate_remote_url(source_id: str, url: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _load_packaged_community_catalog() -> dict:
|
||||
core_pack = _locate_core_pack()
|
||||
path = (
|
||||
core_pack / "bundles" / "catalog.community.json"
|
||||
if core_pack is not None
|
||||
else _repo_root() / "bundles" / "catalog.community.json"
|
||||
)
|
||||
if not path.is_file():
|
||||
raise BundlerError(f"Bundled community catalog not found: {path}")
|
||||
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
|
||||
|
||||
|
||||
def make_catalog_fetcher(*, allow_network: bool = True):
|
||||
"""Return a fetcher callable suitable for :class:`CatalogStack`.
|
||||
|
||||
@@ -108,6 +121,10 @@ def make_catalog_fetcher(*, allow_network: bool = True):
|
||||
scheme = parsed.scheme.lower()
|
||||
|
||||
if scheme == "builtin":
|
||||
if url == "builtin://community":
|
||||
if allow_network:
|
||||
return _http_get_json(source.id, COMMUNITY_CATALOG_URL)
|
||||
return _load_packaged_community_catalog()
|
||||
payload = _BUILTIN_CATALOGS.get(url)
|
||||
if payload is None:
|
||||
raise BundlerError(f"Unknown built-in catalog '{url}'.")
|
||||
|
||||
@@ -149,7 +149,10 @@ class CatalogStackBase:
|
||||
)
|
||||
try:
|
||||
priority = int(raw_priority)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: int(float("inf")) — a YAML ``priority: .inf``
|
||||
# would otherwise escape as an uncaught traceback instead of the
|
||||
# clean validation error.
|
||||
raise self._validation_error(
|
||||
f"Invalid catalog config {config_path}: "
|
||||
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
||||
|
||||
@@ -86,7 +86,7 @@ def register(app: typer.Typer) -> None:
|
||||
help="Name for your new project directory (optional if using --here, or use '.' for current directory)",
|
||||
),
|
||||
script_type: str = typer.Option(
|
||||
None, "--script", help="Script type to use: sh or ps"
|
||||
None, "--script", help="Script type to use: sh, ps, or py"
|
||||
),
|
||||
ignore_agent_tools: bool = typer.Option(
|
||||
False,
|
||||
@@ -458,6 +458,7 @@ def register(app: typer.Typer) -> None:
|
||||
script_type=selected_script,
|
||||
raw_options=integration_options,
|
||||
parsed_options=integration_parsed_options or None,
|
||||
project_root=project_path,
|
||||
)
|
||||
_write_integration_json(
|
||||
project_path,
|
||||
@@ -478,7 +479,7 @@ def register(app: typer.Typer) -> None:
|
||||
tracker=tracker,
|
||||
force=force,
|
||||
invoke_separator=resolved_integration.effective_invoke_separator(
|
||||
integration_parsed_options
|
||||
integration_parsed_options, project_root=project_path
|
||||
),
|
||||
)
|
||||
tracker.complete(
|
||||
@@ -532,10 +533,8 @@ def register(app: typer.Typer) -> None:
|
||||
"feature_numbering": "sequential",
|
||||
"speckit_version": get_speckit_version(),
|
||||
}
|
||||
from ..integrations.base import SkillsIntegration as _SkillsPersist
|
||||
|
||||
if isinstance(resolved_integration, _SkillsPersist) or getattr(
|
||||
resolved_integration, "_skills_mode", False
|
||||
if resolved_integration.is_skills_mode(
|
||||
integration_parsed_options or None, project_root=project_path
|
||||
):
|
||||
init_opts["ai_skills"] = True
|
||||
save_init_options(project_path, init_opts)
|
||||
@@ -683,11 +682,9 @@ def register(app: typer.Typer) -> None:
|
||||
steps_lines.append("1. You're already in the project directory!")
|
||||
step_num = 2
|
||||
|
||||
from ..integrations.base import SkillsIntegration as _SkillsInt
|
||||
|
||||
_is_skills_integration = isinstance(
|
||||
resolved_integration, _SkillsInt
|
||||
) or getattr(resolved_integration, "_skills_mode", False)
|
||||
_is_skills_integration = resolved_integration.is_skills_mode(
|
||||
integration_parsed_options or None, project_root=project_path
|
||||
)
|
||||
|
||||
codex_skill_mode = selected_ai == "codex" and _is_skills_integration
|
||||
zcode_skill_mode = selected_ai == "zcode" and _is_skills_integration
|
||||
@@ -703,6 +700,7 @@ def register(app: typer.Typer) -> None:
|
||||
zed_skill_mode = selected_ai == "zed" and _is_skills_integration
|
||||
grok_skill_mode = selected_ai == "grok" and _is_skills_integration
|
||||
cline_skill_mode = selected_ai == "cline"
|
||||
bob_skill_mode = selected_ai == "bob" and _is_skills_integration
|
||||
native_skill_mode = (
|
||||
codex_skill_mode
|
||||
or zcode_skill_mode
|
||||
@@ -715,6 +713,7 @@ def register(app: typer.Typer) -> None:
|
||||
or devin_skill_mode
|
||||
or zed_skill_mode
|
||||
or grok_skill_mode
|
||||
or bob_skill_mode
|
||||
)
|
||||
|
||||
if codex_skill_mode:
|
||||
@@ -752,6 +751,11 @@ def register(app: typer.Typer) -> None:
|
||||
f"{step_num}. Start Grok Build in this project directory; spec-kit skills were installed to [cyan].grok/skills[/cyan]"
|
||||
)
|
||||
step_num += 1
|
||||
if bob_skill_mode:
|
||||
steps_lines.append(
|
||||
f"{step_num}. Start Bob in this project directory; spec-kit skills were installed to [cyan].bob/skills[/cyan]"
|
||||
)
|
||||
step_num += 1
|
||||
usage_label = "skills" if native_skill_mode else "slash commands"
|
||||
|
||||
from .._invocation_style import (
|
||||
|
||||
@@ -9,11 +9,13 @@ without bloating the core framework.
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
@@ -101,6 +103,51 @@ def _load_core_command_names() -> frozenset[str]:
|
||||
CORE_COMMAND_NAMES = _load_core_command_names()
|
||||
|
||||
|
||||
def _fsync_fd(fd: int) -> None:
|
||||
"""Sync a file descriptor, raising on real storage errors."""
|
||||
try:
|
||||
os.fsync(fd)
|
||||
except AttributeError:
|
||||
return
|
||||
except NotImplementedError:
|
||||
return
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
|
||||
return
|
||||
raise
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
"""Sync a directory when the platform supports it."""
|
||||
if not path.exists():
|
||||
return
|
||||
if os.name == "nt":
|
||||
return
|
||||
try:
|
||||
dir_fd = os.open(str(path), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
except (AttributeError, NotImplementedError):
|
||||
return
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
|
||||
return
|
||||
try:
|
||||
dir_fd = os.open(str(path), os.O_RDONLY)
|
||||
except (AttributeError, NotImplementedError):
|
||||
return
|
||||
except OSError as exc2:
|
||||
if exc2.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
|
||||
return
|
||||
raise
|
||||
try:
|
||||
_fsync_fd(dir_fd)
|
||||
finally:
|
||||
try:
|
||||
os.close(dir_fd)
|
||||
except OSError:
|
||||
# Cleanup after an fsync failure should not mask the original error.
|
||||
pass
|
||||
|
||||
|
||||
class ExtensionError(Exception):
|
||||
"""Base exception for extension-related errors."""
|
||||
|
||||
@@ -136,7 +183,7 @@ def normalize_priority(value: Any, default: int = DEFAULT_HOOK_PRIORITY) -> int:
|
||||
return default
|
||||
try:
|
||||
priority = int(value)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return default
|
||||
return priority if priority >= 1 else default
|
||||
|
||||
@@ -699,6 +746,55 @@ class ExtensionManager:
|
||||
self.extensions_dir = project_root / ".specify" / "extensions"
|
||||
self.registry = ExtensionRegistry(self.extensions_dir)
|
||||
|
||||
def _rescue_staging_dir(self, extension_id: str) -> Path:
|
||||
"""Fixed-length staging directory path for a preserved-config rescue.
|
||||
|
||||
The extension ID can be arbitrarily long (manifest validation caps only
|
||||
the character set, not the length), so embedding it verbatim in a single
|
||||
path component could push the ``.rescue-staging-<id>`` directory past a
|
||||
filesystem's per-component byte limit and make every reinstall after
|
||||
``--keep-config`` fail with ``ENAMETOOLONG`` even though the extension
|
||||
installs fine at ``dest_dir``. Hash the ID to a fixed-length suffix so
|
||||
the component length is bounded regardless of ID length.
|
||||
"""
|
||||
digest = hashlib.sha256(extension_id.encode("utf-8")).hexdigest()[:16]
|
||||
return self.extensions_dir / f".rescue-staging-{digest}"
|
||||
|
||||
@staticmethod
|
||||
def _has_keep_config_marker(directory: Path) -> bool:
|
||||
"""Return True when *directory* contains a valid ``.keep-config`` marker.
|
||||
|
||||
The marker is a regular (non-symlink) file written by
|
||||
``remove(..., keep_config=True)`` to record explicit provenance. Its
|
||||
content is intentionally empty — only presence matters, not content.
|
||||
The symlink guard prevents a crafted symlink from fooling the check.
|
||||
"""
|
||||
marker = directory / ".keep-config"
|
||||
return marker.is_file() and not marker.is_symlink()
|
||||
|
||||
@staticmethod
|
||||
def _is_legacy_keep_config_leftover(directory: Path) -> bool:
|
||||
"""Return True for the pre-marker ``remove(..., keep_config=True)`` layout.
|
||||
|
||||
Older CLI releases preserved only top-level config files and removed every
|
||||
other entry, but they did not write ``.keep-config``. Recognize that exact
|
||||
config-only leftover so upgrades still preserve user config, while
|
||||
excluding partially-failed installs that still contain copied payload such
|
||||
as ``extension.yml`` or command directories.
|
||||
"""
|
||||
if not directory.is_dir() or directory.is_symlink():
|
||||
return False
|
||||
|
||||
has_config = False
|
||||
for entry in directory.iterdir():
|
||||
if entry.name.endswith(("-config.yml", "-config.local.yml")) and (
|
||||
entry.is_file() or entry.is_symlink()
|
||||
):
|
||||
has_config = True
|
||||
continue
|
||||
return False
|
||||
return has_config
|
||||
|
||||
@staticmethod
|
||||
def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, str]:
|
||||
"""Collect command and alias names declared by a manifest.
|
||||
@@ -1428,12 +1524,421 @@ class ExtensionManager:
|
||||
backup_config_dir.unlink()
|
||||
did_remove = self.remove(manifest.id)
|
||||
|
||||
# Load and validate .extensionignore BEFORE reading/creating the rescue
|
||||
# staging directory (and thus before deleting dest_dir). The loader can
|
||||
# raise ValidationError (invalid UTF-8) or OSError; doing it first means
|
||||
# such a failure aborts while the kept config is still authoritative in
|
||||
# its documented location, rather than leaving a freshly published
|
||||
# staging copy that a later retry (after the user edits the kept config)
|
||||
# would reload and use to overwrite the newer bytes. Any staging left by
|
||||
# an earlier destructive attempt is intentionally left intact here.
|
||||
ignore_fn = self._load_extensionignore(source_dir)
|
||||
|
||||
# Rescue any config files left behind by a prior `remove --keep-config`.
|
||||
# When an extension is removed with --keep-config, it is no longer in
|
||||
# the registry but its config files remain in dest_dir. A subsequent
|
||||
# plain (non-force) install would delete that directory unconditionally,
|
||||
# silently discarding the preserved config. We read those files into
|
||||
# memory and also write a durable staging copy outside dest_dir so
|
||||
# that a partial rmtree, failed copytree, or partial restore cannot
|
||||
# permanently discard the user's original bytes on a retry. The
|
||||
# staging dir is removed only after every config has been successfully
|
||||
# restored.
|
||||
stranded_configs: dict[str, tuple[bytes, int]] = {}
|
||||
rescue_staging_dir = self._rescue_staging_dir(manifest.id)
|
||||
# A staging directory is trusted only when this completion marker is
|
||||
# present. The marker is written after every staged file is complete
|
||||
# and removed before the non-atomic cleanup, so a crash mid-staging or
|
||||
# mid-cleanup can never leave a partial directory that a retry mistakes
|
||||
# for a complete durable backup.
|
||||
rescue_complete_marker = rescue_staging_dir / ".rescue-complete"
|
||||
staging_is_complete = (
|
||||
rescue_staging_dir.is_dir()
|
||||
and not rescue_staging_dir.is_symlink()
|
||||
and rescue_complete_marker.is_file()
|
||||
and not rescue_complete_marker.is_symlink()
|
||||
)
|
||||
|
||||
if staging_is_complete and not self.registry.is_installed(manifest.id):
|
||||
# A previous install attempt staged the configs but never
|
||||
# completed cleanly. Reload from the durable backup so the
|
||||
# original bytes are used on retry rather than whatever
|
||||
# mixture of packaged defaults and partial restores remains
|
||||
# on disk. Only load non-symlinked files whose names match
|
||||
# the two recognised config suffixes so a tampered staging
|
||||
# directory cannot inject arbitrary files.
|
||||
#
|
||||
# A complete staging directory proves only that staging finished,
|
||||
# not that dest_dir was ever modified: a crash after staging was
|
||||
# synced but before the rmtree below leaves the live kept config
|
||||
# intact. If the user then edits that live config before retrying,
|
||||
# blindly preferring the staged bytes would silently overwrite the
|
||||
# newer config. The staged and live copies are indistinguishable
|
||||
# in provenance from disk alone (a genuine post-crash edit vs. a
|
||||
# packaged default written by a partially-completed copytree), so
|
||||
# when a live config disagrees with its staged copy we must not
|
||||
# silently pick either — preserve both and abort, letting the user
|
||||
# resolve it. dest_dir is still untouched here, so raising is safe.
|
||||
def _recognized_config_names(
|
||||
directory: Path, *, follow_symlinks: bool = True
|
||||
) -> set[str]:
|
||||
names: set[str] = set()
|
||||
if not directory.is_dir():
|
||||
return names
|
||||
for entry in directory.iterdir():
|
||||
if not entry.name.endswith(
|
||||
("-config.yml", "-config.local.yml")
|
||||
):
|
||||
continue
|
||||
if follow_symlinks:
|
||||
if entry.is_file() and not entry.is_symlink():
|
||||
names.add(entry.name)
|
||||
else:
|
||||
# Include symlinks without following them so that
|
||||
# live-only symlinked configs are detected and
|
||||
# preserved rather than silently deleted.
|
||||
if entry.is_file() or entry.is_symlink():
|
||||
names.add(entry.name)
|
||||
return names
|
||||
|
||||
conflicting: set[str] = set()
|
||||
staged_names = _recognized_config_names(rescue_staging_dir)
|
||||
live_names = _recognized_config_names(
|
||||
dest_dir, follow_symlinks=False
|
||||
)
|
||||
|
||||
def _matches_source_config_baseline(config_name: str) -> bool:
|
||||
source_file = source_dir / config_name
|
||||
live_file = dest_dir / config_name
|
||||
if source_file.is_symlink() or live_file.is_symlink():
|
||||
return False
|
||||
if not source_file.is_file() or not live_file.is_file():
|
||||
return False
|
||||
try:
|
||||
source_stat = source_file.stat()
|
||||
source_bytes = source_file.read_bytes()
|
||||
live_stat = live_file.stat()
|
||||
live_bytes = live_file.read_bytes()
|
||||
except OSError:
|
||||
return False
|
||||
return live_bytes == source_bytes and stat.S_IMODE(
|
||||
live_stat.st_mode
|
||||
) == stat.S_IMODE(source_stat.st_mode)
|
||||
|
||||
# A live-only config created after the interrupted attempt is not
|
||||
# enumerated by staging, so without this it would be silently
|
||||
# deleted by the rmtree below and its bytes lost. Live-only files
|
||||
# that still match the current package baseline are safe: they were
|
||||
# copied by the interrupted install and can be recreated on retry.
|
||||
# Only truly divergent live-only configs are conflicts.
|
||||
live_only = live_names - staged_names
|
||||
conflicting.update(
|
||||
name
|
||||
for name in live_only
|
||||
if not _matches_source_config_baseline(name)
|
||||
)
|
||||
# Load original permission bits from the sidecar JSON written by
|
||||
# the staging step. Staged files are kept at mode 0o600 so that
|
||||
# rmtree always succeeds on Windows, so staged_stat.st_mode would
|
||||
# always be 0o600 and must not be used for mode comparisons or
|
||||
# restoration; the sidecar records the true original mode.
|
||||
rescue_modes_file = rescue_staging_dir / ".rescue-modes.json"
|
||||
_staged_modes: dict[str, int] = {}
|
||||
if rescue_modes_file.is_file() and not rescue_modes_file.is_symlink():
|
||||
try:
|
||||
_loaded_modes = json.loads(rescue_modes_file.read_bytes())
|
||||
except (OSError, ValueError):
|
||||
# Ignore unreadable/invalid sidecar metadata and fall back
|
||||
# to each staged file's mode for compatibility.
|
||||
pass
|
||||
else:
|
||||
# json.loads() succeeds for any valid JSON document, so a
|
||||
# sidecar containing e.g. `[]` or a string would otherwise
|
||||
# crash later at _staged_modes.get() or stat.S_IMODE().
|
||||
# Accept only a mapping of string filenames to integer modes
|
||||
# (bool is rejected despite subclassing int); anything else
|
||||
# falls back to each staged file's own mode.
|
||||
if isinstance(_loaded_modes, dict) and all(
|
||||
isinstance(name, str)
|
||||
and isinstance(recorded_mode, int)
|
||||
and not isinstance(recorded_mode, bool)
|
||||
for name, recorded_mode in _loaded_modes.items()
|
||||
):
|
||||
_staged_modes = _loaded_modes
|
||||
for staged_name in sorted(staged_names):
|
||||
staged_file = rescue_staging_dir / staged_name
|
||||
staged_stat = staged_file.stat()
|
||||
staged_bytes = staged_file.read_bytes()
|
||||
# Prefer the sidecar-recorded mode; fall back to the staged
|
||||
# file's own mode for backwards-compat with staging dirs
|
||||
# written before the sidecar was introduced.
|
||||
staged_mode = _staged_modes.get(
|
||||
staged_name, stat.S_IMODE(staged_stat.st_mode)
|
||||
)
|
||||
live_file = dest_dir / staged_name
|
||||
if live_file.is_symlink():
|
||||
# A user may have replaced the live config with a symlink
|
||||
# after the interrupted attempt. It cannot be compared by
|
||||
# bytes/mode against the staged copy, and the rmtree below
|
||||
# would silently delete this newer choice and restore the
|
||||
# older staged file. Treat any live symlink as a conflict so
|
||||
# both are preserved and the user resolves it.
|
||||
conflicting.add(staged_name)
|
||||
elif live_file.is_file():
|
||||
# A live config that cannot be read or stat'ed must not be
|
||||
# treated as non-conflicting: the rmtree below would delete
|
||||
# it and restore the stale staged copy. Abort while dest_dir
|
||||
# is untouched so no newer or permission-restricted config is
|
||||
# lost. Divergence also includes permission-only edits (for
|
||||
# example tightening a secret-bearing config from 0644 to
|
||||
# 0600), which byte equality alone would miss and then revert.
|
||||
try:
|
||||
live_stat = live_file.stat()
|
||||
live_bytes = live_file.read_bytes()
|
||||
except OSError:
|
||||
conflicting.add(staged_name)
|
||||
else:
|
||||
if live_bytes != staged_bytes or stat.S_IMODE(
|
||||
live_stat.st_mode
|
||||
) != staged_mode:
|
||||
conflicting.add(staged_name)
|
||||
stranded_configs[staged_name] = (staged_bytes, staged_mode)
|
||||
if conflicting:
|
||||
# Split into two cases for accurate user guidance: files that
|
||||
# exist in both locations but have diverged, and files that
|
||||
# exist only in the live directory with no rescue-backup copy.
|
||||
both_diverged = conflicting - live_only
|
||||
live_only_conflict = conflicting & live_only
|
||||
msg_parts: list[str] = [
|
||||
f"Preserved extension config conflict for '{manifest.id}':"
|
||||
]
|
||||
if both_diverged:
|
||||
names = ", ".join(sorted(both_diverged))
|
||||
msg_parts.append(
|
||||
f"The current config(s) ({names}) in {dest_dir} differ"
|
||||
f" from their rescued backup in {rescue_staging_dir}."
|
||||
" Both copies have been preserved."
|
||||
)
|
||||
if live_only_conflict:
|
||||
names = ", ".join(sorted(live_only_conflict))
|
||||
msg_parts.append(
|
||||
f"The config(s) ({names}) exist only in {dest_dir}"
|
||||
f" with no counterpart in the rescued backup at"
|
||||
f" {rescue_staging_dir}."
|
||||
)
|
||||
msg_parts.append(
|
||||
f"Reconcile {dest_dir} and {rescue_staging_dir} to the"
|
||||
f" desired final state, delete {rescue_staging_dir},"
|
||||
" then reinstall."
|
||||
)
|
||||
raise ValidationError(" ".join(msg_parts))
|
||||
elif (
|
||||
dest_dir.exists()
|
||||
and not self.registry.is_installed(manifest.id)
|
||||
and (
|
||||
self._has_keep_config_marker(dest_dir)
|
||||
or self._is_legacy_keep_config_leftover(dest_dir)
|
||||
)
|
||||
):
|
||||
for cfg_file in (
|
||||
list(dest_dir.glob("*-config.yml"))
|
||||
+ list(dest_dir.glob("*-config.local.yml"))
|
||||
):
|
||||
if cfg_file.is_symlink():
|
||||
# `remove --keep-config` preserves a symlinked config
|
||||
# because Path.is_file() follows symlinks. Its bytes cannot
|
||||
# be safely rescued (the target may live outside dest_dir),
|
||||
# and the rmtree below would delete the link and silently
|
||||
# discard the kept configuration. Reject the reinstall while
|
||||
# dest_dir is untouched so the user resolves it rather than
|
||||
# losing the linked config.
|
||||
raise ValidationError(
|
||||
"Preserved extension config for "
|
||||
f"'{manifest.id}' is a symlink ({cfg_file.name}) in "
|
||||
f"{dest_dir}, which cannot be safely rescued during "
|
||||
"reinstall. Resolve manually — replace the symlink with "
|
||||
"a regular file or remove it — then reinstall."
|
||||
)
|
||||
if cfg_file.is_file():
|
||||
stranded_configs[cfg_file.name] = (
|
||||
cfg_file.read_bytes(),
|
||||
cfg_file.stat().st_mode,
|
||||
)
|
||||
|
||||
if stranded_configs and not staging_is_complete:
|
||||
# Write a durable backup outside dest_dir before any
|
||||
# destructive operation so the original bytes survive a
|
||||
# crash or partial failure at any later step. The staging
|
||||
# dir is cleaned up only after every restore succeeds.
|
||||
#
|
||||
# Any pre-existing staging dir here lacks the completion marker
|
||||
# (staging_is_complete is False), so it is a stale partial from an
|
||||
# interrupted attempt — remove it first for a clean write.
|
||||
if rescue_staging_dir.is_symlink():
|
||||
rescue_staging_dir.unlink()
|
||||
elif rescue_staging_dir.is_dir():
|
||||
shutil.rmtree(rescue_staging_dir)
|
||||
elif rescue_staging_dir.exists():
|
||||
rescue_staging_dir.unlink()
|
||||
try:
|
||||
rescue_staging_dir.mkdir(parents=True, exist_ok=True)
|
||||
for filename, (content, mode) in stranded_configs.items():
|
||||
staged = rescue_staging_dir / filename
|
||||
# Create the staging file with mode 0600 before writing so
|
||||
# the preserved bytes are never transiently readable by other
|
||||
# local users, even on a umask that would produce 0644.
|
||||
# O_BINARY (0 on POSIX) is required so Windows does not open
|
||||
# the descriptor in text mode and translate the preserved
|
||||
# bytes' "\n" into "\r\n" as they are written.
|
||||
fd = os.open(
|
||||
str(staged),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0),
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
# os.write() may write fewer bytes than requested, so
|
||||
# loop until the whole buffer is on disk — a truncated
|
||||
# "durable" backup would be trusted over the intact
|
||||
# config on a retry and cause silent data loss.
|
||||
view = memoryview(content)
|
||||
written = 0
|
||||
while written < len(view):
|
||||
written += os.write(fd, view[written:])
|
||||
# Do NOT chmod the staged file: setting a read-only
|
||||
# mode (e.g. 0o444) makes the file undeletable on
|
||||
# Windows and causes shutil.rmtree to fail during
|
||||
# cleanup. Original modes are recorded separately in
|
||||
# .rescue-modes.json so they can be reapplied when the
|
||||
# config is actually restored.
|
||||
_fsync_fd(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
# Persist the original permission bits in a sidecar JSON file
|
||||
# so a retry can correctly reapply them even though the staged
|
||||
# files themselves are kept at their creation mode (0o600).
|
||||
rescue_modes_file = rescue_staging_dir / ".rescue-modes.json"
|
||||
modes_payload = json.dumps(
|
||||
{
|
||||
filename: stat.S_IMODE(mode)
|
||||
for filename, (_, mode) in stranded_configs.items()
|
||||
},
|
||||
sort_keys=True,
|
||||
).encode()
|
||||
modes_fd = os.open(
|
||||
str(rescue_modes_file),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0),
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
view = memoryview(modes_payload)
|
||||
written = 0
|
||||
while written < len(view):
|
||||
written += os.write(modes_fd, view[written:])
|
||||
_fsync_fd(modes_fd)
|
||||
finally:
|
||||
os.close(modes_fd)
|
||||
# Flush the staging directory metadata before publishing the
|
||||
# completion marker so a crash cannot leave a visible marker with
|
||||
# only a subset of staged files.
|
||||
_fsync_directory(rescue_staging_dir)
|
||||
# Write the completion marker only after every staged file is
|
||||
# fully written so a retry trusts staging only when it is whole.
|
||||
marker_fd = os.open(
|
||||
str(rescue_complete_marker),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
_fsync_fd(marker_fd)
|
||||
finally:
|
||||
os.close(marker_fd)
|
||||
_fsync_directory(rescue_staging_dir)
|
||||
_fsync_directory(rescue_staging_dir.parent)
|
||||
except BaseException:
|
||||
# Durable staging failed (or was interrupted). Continuing with
|
||||
# only the in-memory copy would reintroduce the permanent-loss
|
||||
# path this staging exists to close: the rmtree below could
|
||||
# delete the originals and a later restore failure would leave
|
||||
# no on-disk copy. dest_dir is still untouched here, so clean
|
||||
# up the partial staging dir and abort the install instead of
|
||||
# proceeding destructively.
|
||||
shutil.rmtree(rescue_staging_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
# Install extension (dest_dir computed above during self-install guard)
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
|
||||
ignore_fn = self._load_extensionignore(source_dir)
|
||||
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
|
||||
def _restore_stranded_config_file(
|
||||
target: Path, content: bytes, preserved_mode: int
|
||||
) -> None:
|
||||
tmp_path: Path | None = None
|
||||
try:
|
||||
# A short fixed prefix, not f".{target.name}.": the preserved
|
||||
# config filename may itself already be near the filesystem's
|
||||
# per-component byte limit, and NamedTemporaryFile appends a
|
||||
# random suffix to the prefix — reusing the full name would push
|
||||
# the temp file past the limit and raise ENAMETOOLONG on every
|
||||
# retry. tempfile already guarantees collision avoidance.
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
dir=target.parent,
|
||||
prefix=".cfg-restore.",
|
||||
delete=False,
|
||||
) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
tmp.write(content)
|
||||
tmp.flush()
|
||||
_fsync_fd(tmp.fileno())
|
||||
try:
|
||||
tmp_path.chmod(stat.S_IMODE(preserved_mode))
|
||||
except (NotImplementedError, OSError):
|
||||
pass # Best-effort; chmod may not be supported on all platforms.
|
||||
os.replace(tmp_path, target)
|
||||
try:
|
||||
target_fd = os.open(str(target), os.O_RDONLY)
|
||||
except (AttributeError, OSError, NotImplementedError):
|
||||
target_fd = None
|
||||
try:
|
||||
if target_fd is not None:
|
||||
_fsync_fd(target_fd)
|
||||
finally:
|
||||
if target_fd is not None:
|
||||
try:
|
||||
os.close(target_fd)
|
||||
except OSError:
|
||||
pass # best-effort close during cleanup; ignore errors
|
||||
_fsync_directory(target.parent)
|
||||
except BaseException:
|
||||
if tmp_path is not None and tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
raise
|
||||
|
||||
try:
|
||||
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
|
||||
except BaseException:
|
||||
# copytree failed — dest_dir may be absent or only partially
|
||||
# created. Write the rescued configs back now so they are not
|
||||
# permanently lost even though the install did not complete.
|
||||
if stranded_configs:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
for filename, (content, mode) in stranded_configs.items():
|
||||
target = dest_dir / filename
|
||||
_restore_stranded_config_file(target, content, mode)
|
||||
raise
|
||||
|
||||
# Restore stranded configs rescued before the rmtree above.
|
||||
for filename, (content, mode) in stranded_configs.items():
|
||||
target = dest_dir / filename
|
||||
_restore_stranded_config_file(target, content, mode)
|
||||
|
||||
# NOTE: the durable staging backup is intentionally NOT cleaned up
|
||||
# here. Command/skill/hook registration and the final registry.add()
|
||||
# below can still fail; if we discarded the backup and provenance now,
|
||||
# such a failure would leave the extension unregistered with no durable
|
||||
# rescue copy, so the next plain retry would skip rescue and overwrite
|
||||
# the restored user config with packaged defaults. Cleanup is deferred
|
||||
# until after registry.add() succeeds (see post-commit cleanup below).
|
||||
|
||||
# Register commands with AI agents
|
||||
registered_commands = {}
|
||||
@@ -1496,6 +2001,24 @@ class ExtensionManager:
|
||||
},
|
||||
)
|
||||
|
||||
# Post-commit cleanup: the registry now records this extension as
|
||||
# installed, so the rescue guard (`not self.registry.is_installed`)
|
||||
# will never misread a leftover staging dir on a future run. The
|
||||
# durable backup has therefore served its purpose and can be removed
|
||||
# best-effort — a cleanup failure must not fail an install that has
|
||||
# already committed successfully.
|
||||
if rescue_staging_dir.is_dir() and not rescue_staging_dir.is_symlink():
|
||||
# Remove the completion marker before the non-atomic rmtree so a
|
||||
# crash mid-cleanup cannot leave a staging dir that a retry would
|
||||
# wrongly trust as a complete durable backup.
|
||||
try:
|
||||
rescue_complete_marker.unlink(missing_ok=True)
|
||||
_fsync_directory(rescue_staging_dir)
|
||||
shutil.rmtree(rescue_staging_dir)
|
||||
_fsync_directory(rescue_staging_dir.parent)
|
||||
except OSError:
|
||||
pass # Best-effort; install already committed to the registry.
|
||||
|
||||
return manifest
|
||||
|
||||
def install_from_zip(
|
||||
@@ -1612,6 +2135,12 @@ class ExtensionManager:
|
||||
shutil.rmtree(child)
|
||||
else:
|
||||
child.unlink()
|
||||
# Write a provenance marker so install_from_directory can
|
||||
# distinguish this --keep-config leftover from a directory left
|
||||
# by a partially-failed install (which must not have its
|
||||
# packaged default configs treated as user-preserved data).
|
||||
# Content is intentionally empty — only presence matters.
|
||||
(extension_dir / ".keep-config").write_text("")
|
||||
else:
|
||||
# Backup config files before deleting
|
||||
if extension_dir.exists():
|
||||
@@ -2073,14 +2602,23 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
url: str,
|
||||
timeout: int = 10,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
redirect_validator=None,
|
||||
):
|
||||
"""Open a URL with provider-based auth, trying each configured provider.
|
||||
|
||||
Delegates to :func:`specify_cli.authentication.http.open_url`.
|
||||
*redirect_validator*, when provided, is invoked as ``(old_url, new_url)``
|
||||
before EACH redirect hop so an HTTPS host guarantee can be enforced on
|
||||
every intermediate URL, not just the terminal one.
|
||||
"""
|
||||
from specify_cli.authentication.http import open_url
|
||||
|
||||
return open_url(url, timeout, extra_headers=extra_headers)
|
||||
return open_url(
|
||||
url,
|
||||
timeout,
|
||||
extra_headers=extra_headers,
|
||||
redirect_validator=redirect_validator,
|
||||
)
|
||||
|
||||
def _resolve_github_release_asset_api_url(
|
||||
self,
|
||||
@@ -2304,7 +2842,24 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
|
||||
# Fetch from network
|
||||
try:
|
||||
with self._open_url(entry.url, timeout=10) as response:
|
||||
# Validate EVERY redirect hop, not just the terminal URL. _open_url
|
||||
# follows redirects; _StripAuthOnRedirect drops auth on an HTTPS->HTTP
|
||||
# downgrade AND whenever the redirect leaves the configured trusted
|
||||
# hosts, but the payload itself is still fetched and trusted, and it
|
||||
# supplies each extension's download_url + sha256 (so a redirected
|
||||
# payload defeats sha256 verification). A terminal-only check also
|
||||
# misses an https -> http -> attacker-https chain. redirect_validator
|
||||
# runs before each hop; the final geturl() check is kept as a
|
||||
# belt-and-braces guard. Mirrors bundler/services/adapters.py.
|
||||
def _validate_redirect(_old_url: str, new_url: str) -> None:
|
||||
self._validate_catalog_url(new_url)
|
||||
|
||||
with self._open_url(
|
||||
entry.url, timeout=10, redirect_validator=_validate_redirect
|
||||
) as response:
|
||||
final_url = response.geturl()
|
||||
if final_url != entry.url:
|
||||
self._validate_catalog_url(final_url)
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
self._validate_catalog_payload(catalog_data, entry.url)
|
||||
@@ -2481,7 +3036,18 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
try:
|
||||
import urllib.error
|
||||
|
||||
with self._open_url(catalog_url, timeout=10) as response:
|
||||
# Same redirect hardening as _fetch_single_catalog: validate every
|
||||
# redirect hop AND the final URL so this legacy single-catalog path
|
||||
# is not vulnerable to an HTTPS->HTTP redirected payload either.
|
||||
def _validate_redirect(_old_url: str, new_url: str) -> None:
|
||||
self._validate_catalog_url(new_url)
|
||||
|
||||
with self._open_url(
|
||||
catalog_url, timeout=10, redirect_validator=_validate_redirect
|
||||
) as response:
|
||||
final_url = response.geturl()
|
||||
if final_url != catalog_url:
|
||||
self._validate_catalog_url(final_url)
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
# Validate catalog structure. Reuses the same helper as
|
||||
@@ -2631,8 +3197,20 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
# Validate download URL requires HTTPS (prevent man-in-the-middle attacks)
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(download_url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
# A malformed authority (e.g. an unterminated IPv6 bracket
|
||||
# "https://[::1") makes urlparse / hostname access raise ValueError.
|
||||
# The download_url comes from catalog payload data, so surface a clean
|
||||
# ExtensionError rather than leaking a raw ValueError past the command
|
||||
# handler (which only catches ExtensionError). Mirrors catalogs (#3435)
|
||||
# and workflows/catalog.py (#3484).
|
||||
try:
|
||||
parsed = urlparse(download_url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
raise ExtensionError(
|
||||
f"Extension download URL is malformed: {download_url}"
|
||||
) from None
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
raise ExtensionError(
|
||||
f"Extension download URL must use HTTPS: {download_url}"
|
||||
|
||||
@@ -46,6 +46,7 @@ def with_integration_setting(
|
||||
script_type: str | None = None,
|
||||
raw_options: str | None = None,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Any = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Return integration settings with *key* updated."""
|
||||
settings = integration_settings(state)
|
||||
@@ -63,7 +64,9 @@ def with_integration_setting(
|
||||
elif raw_options is not None:
|
||||
current.pop("parsed_options", None)
|
||||
|
||||
current["invoke_separator"] = integration.effective_invoke_separator(parsed_options)
|
||||
current["invoke_separator"] = integration.effective_invoke_separator(
|
||||
parsed_options, project_root
|
||||
)
|
||||
settings[key] = current
|
||||
return settings
|
||||
|
||||
@@ -73,10 +76,11 @@ def invoke_separator_for_integration(
|
||||
state: dict[str, Any],
|
||||
key: str,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Any = None,
|
||||
) -> str:
|
||||
"""Resolve the invocation separator for stored/default integration state."""
|
||||
if parsed_options is not None:
|
||||
return integration.effective_invoke_separator(parsed_options)
|
||||
return integration.effective_invoke_separator(parsed_options, project_root)
|
||||
|
||||
setting = integration_setting(state, key)
|
||||
stored_separator = setting.get("invoke_separator")
|
||||
@@ -85,6 +89,6 @@ def invoke_separator_for_integration(
|
||||
|
||||
stored_parsed = setting.get("parsed_options")
|
||||
if isinstance(stored_parsed, dict):
|
||||
return integration.effective_invoke_separator(stored_parsed)
|
||||
return integration.effective_invoke_separator(stored_parsed, project_root)
|
||||
|
||||
return integration.effective_invoke_separator(None)
|
||||
return integration.effective_invoke_separator(None, project_root)
|
||||
|
||||
@@ -272,24 +272,19 @@ def _update_init_options_for_integration(
|
||||
load_init_options,
|
||||
save_init_options,
|
||||
)
|
||||
from .base import SkillsIntegration
|
||||
opts = load_init_options(project_root)
|
||||
opts["integration"] = integration.key
|
||||
opts["ai"] = integration.key
|
||||
opts["speckit_version"] = _get_speckit_version()
|
||||
if script_type:
|
||||
opts["script"] = script_type
|
||||
# Skills mode is either intrinsic (SkillsIntegration), set on the instance
|
||||
# during setup() (_skills_mode), or requested via parsed options (e.g.
|
||||
# Copilot's --skills, persisted as parsed_options["skills"]). The latter is
|
||||
# the only signal available on the `use` path, where no setup() runs and a
|
||||
# fresh integration instance has _skills_mode == False (issue #3550).
|
||||
skills_mode = (
|
||||
isinstance(integration, SkillsIntegration)
|
||||
or getattr(integration, "_skills_mode", False)
|
||||
or bool((parsed_options or {}).get("skills"))
|
||||
)
|
||||
if skills_mode:
|
||||
# Whether skills mode is active is owned by each integration via the
|
||||
# ``is_skills_mode`` hook (base default honors ``--skills``;
|
||||
# SkillsIntegration returns True; skills-first integrations with a legacy
|
||||
# opt-out such as Bob override it). This keeps shared code free of
|
||||
# ``isinstance`` / ``_skills_mode`` probing. Passing parsed_options lets it
|
||||
# work on the ``use``/``install`` path where no setup() runs (issue #3550).
|
||||
if integration.is_skills_mode(parsed_options, project_root=project_root):
|
||||
opts["ai_skills"] = True
|
||||
else:
|
||||
opts.pop("ai_skills", None)
|
||||
@@ -325,6 +320,7 @@ def _set_default_integration(
|
||||
script_type=resolved_script,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
project_root=project_root,
|
||||
)
|
||||
|
||||
if refresh_templates:
|
||||
@@ -333,7 +329,8 @@ def _set_default_integration(
|
||||
project_root,
|
||||
resolved_script,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
integration, {"integration_settings": settings}, key, parsed_options
|
||||
integration, {"integration_settings": settings}, key, parsed_options,
|
||||
project_root=project_root,
|
||||
),
|
||||
force=refresh_templates_force,
|
||||
refresh_managed=True,
|
||||
|
||||
@@ -38,7 +38,7 @@ from ._helpers import (
|
||||
@integration_app.command("install")
|
||||
def integration_install(
|
||||
key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
|
||||
force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"),
|
||||
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
||||
):
|
||||
@@ -127,7 +127,8 @@ def integration_install(
|
||||
project_root,
|
||||
selected_script,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
infra_integration, current, infra_key, infra_parsed
|
||||
infra_integration, current, infra_key, infra_parsed,
|
||||
project_root=project_root,
|
||||
),
|
||||
)
|
||||
if os.name != "nt":
|
||||
@@ -155,10 +156,16 @@ def integration_install(
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
project_root=project_root,
|
||||
)
|
||||
_write_integration_json(project_root, new_default, new_installed, settings)
|
||||
if new_default == integration.key:
|
||||
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
|
||||
_update_init_options_for_integration(
|
||||
project_root,
|
||||
integration,
|
||||
script_type=selected_script,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
else:
|
||||
_refresh_init_options_speckit_version(project_root)
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""specify integration switch / upgrade command handlers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import PurePath
|
||||
from pathlib import Path, PurePath
|
||||
|
||||
import typer
|
||||
|
||||
@@ -40,10 +41,97 @@ from ._helpers import (
|
||||
)
|
||||
|
||||
|
||||
def _manifest_tracks_skill_layout(manifest) -> bool:
|
||||
"""Return True when *manifest* tracks any skills-layout artifact.
|
||||
|
||||
A skill scaffold is written as ``.../speckit-<name>/SKILL.md``, so a
|
||||
manifest whose tracked files include a ``/SKILL.md`` key is in the skills
|
||||
layout; otherwise it is in the command layout. Used by ``upgrade`` to
|
||||
detect a dual-mode agent (e.g. Bob) flipping between the legacy commands
|
||||
layout and the skills layout so orphaned extension artifacts from the old
|
||||
layout can be reconciled.
|
||||
"""
|
||||
return any(str(rel).endswith("/SKILL.md") for rel in manifest.files)
|
||||
|
||||
|
||||
class _PresetRegistryUnreadableError(Exception):
|
||||
"""Raised when an existing preset registry cannot be read or parsed.
|
||||
|
||||
Distinct from a *genuinely absent* registry (no presets installed): an
|
||||
unreadable registry means we cannot verify whether preset overrides would
|
||||
be orphaned by a layout change, so the migration must be rejected rather
|
||||
than proceeding on a false "no presets" assumption.
|
||||
"""
|
||||
|
||||
|
||||
def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str]:
|
||||
"""Return IDs of installed presets with artifacts registered for *agent_key*.
|
||||
|
||||
Presets register command overrides for every detected agent and mirror
|
||||
skills for the active skills agent, tracking the result in each preset's
|
||||
``registered_commands`` / ``registered_skills`` metadata. There is no
|
||||
agent-scoped preset re-registration mechanism, so a command↔skills *layout
|
||||
change* cannot reconcile those artifacts (see ``integration_upgrade``).
|
||||
Callers use this to detect the unsafe case and reject the migration rather
|
||||
than silently orphaning preset files / leaving stale registry entries.
|
||||
|
||||
Fails **closed**: a genuinely absent registry (no presets ever installed)
|
||||
returns an empty list, but if the registry file exists and cannot be read
|
||||
or parsed (e.g. a permission error or corruption) this raises
|
||||
:class:`_PresetRegistryUnreadableError`. Reporting "no presets" in that
|
||||
case would let a ``--force`` layout-changing upgrade delete
|
||||
preset-overridden files while their registry state can't be reconciled —
|
||||
the exact inconsistency the guard exists to prevent.
|
||||
"""
|
||||
from ..presets import PresetRegistry
|
||||
|
||||
registry_path = (
|
||||
Path(project_root) / ".specify" / "presets" / PresetRegistry.REGISTRY_FILE
|
||||
)
|
||||
# Genuinely absent registry → no presets installed → safe to proceed.
|
||||
if not registry_path.exists():
|
||||
return []
|
||||
|
||||
# The registry exists: any failure to read or parse it must surface as an
|
||||
# error, not be swallowed into an empty ("no presets") result.
|
||||
try:
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise _PresetRegistryUnreadableError(str(exc)) from exc
|
||||
if not isinstance(data, dict) or not isinstance(data.get("presets", {}), dict):
|
||||
raise _PresetRegistryUnreadableError(
|
||||
"preset registry structure is malformed"
|
||||
)
|
||||
|
||||
affected: list[str] = []
|
||||
for preset_id, meta in data.get("presets", {}).items():
|
||||
# A malformed entry means we cannot verify whether this preset owns
|
||||
# artifacts for the agent, so fail closed rather than skip it.
|
||||
if not isinstance(meta, dict):
|
||||
raise _PresetRegistryUnreadableError(
|
||||
f"preset '{preset_id}' entry is malformed"
|
||||
)
|
||||
registered_commands = meta.get("registered_commands", {})
|
||||
if not isinstance(registered_commands, dict):
|
||||
raise _PresetRegistryUnreadableError(
|
||||
f"preset '{preset_id}' registered_commands is malformed"
|
||||
)
|
||||
registered_skills = meta.get("registered_skills", [])
|
||||
if not isinstance(registered_skills, (list, tuple)):
|
||||
raise _PresetRegistryUnreadableError(
|
||||
f"preset '{preset_id}' registered_skills is malformed"
|
||||
)
|
||||
has_commands = bool(registered_commands.get(agent_key))
|
||||
has_skills = bool(registered_skills)
|
||||
if has_commands or has_skills:
|
||||
affected.append(preset_id)
|
||||
return affected
|
||||
|
||||
|
||||
@integration_app.command("switch")
|
||||
def integration_switch(
|
||||
target: str = typer.Argument(help="Integration key to switch to"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
|
||||
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
|
||||
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
|
||||
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
|
||||
@@ -236,7 +324,8 @@ def integration_switch(
|
||||
force=refresh_shared_infra,
|
||||
refresh_managed=True,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
target_integration, current, target, parsed_options
|
||||
target_integration, current, target, parsed_options,
|
||||
project_root=project_root,
|
||||
),
|
||||
refresh_hint=(
|
||||
"To overwrite customizations, re-run with "
|
||||
@@ -336,7 +425,7 @@ def integration_switch(
|
||||
def integration_upgrade(
|
||||
key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"),
|
||||
force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
|
||||
integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"),
|
||||
):
|
||||
"""Upgrade an integration by reinstalling with diff-aware file handling.
|
||||
@@ -398,6 +487,56 @@ def integration_upgrade(
|
||||
integration, current, key, integration_options
|
||||
)
|
||||
|
||||
# Guard: reject a command↔skills layout change while preset overrides are
|
||||
# installed for this agent (review #3415). A dual-mode agent (e.g. Bob)
|
||||
# can flip layout across an upgrade (``--skills`` / ``--legacy-commands``).
|
||||
# Extension artifacts are reconciled after the flip (see below), but preset
|
||||
# artifacts cannot be: there is no agent-scoped preset re-registration
|
||||
# anywhere in the CLI, so migrating would delete a preset's old-layout
|
||||
# files without recreating them in the new layout and leave the preset
|
||||
# registry claiming artifacts that no longer exist. Detect the intended
|
||||
# layout (``is_skills_mode`` reflects the resolved flags/disk state, so a
|
||||
# plain same-layout upgrade is unaffected) and bail out *before* any
|
||||
# mutation with an actionable error so the project is never left in a
|
||||
# half-migrated, inconsistent state.
|
||||
if _manifest_tracks_skill_layout(old_manifest) != integration.is_skills_mode(
|
||||
parsed_options, project_root
|
||||
):
|
||||
try:
|
||||
affected_presets = _installed_presets_affecting_agent(project_root, key)
|
||||
except _PresetRegistryUnreadableError as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Cannot change '{key}' command layout: the "
|
||||
f"preset registry could not be read to verify installed presets."
|
||||
)
|
||||
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
|
||||
console.print(
|
||||
"A layout change cannot reconcile preset artifacts, so the "
|
||||
"migration is refused while the preset registry state is "
|
||||
"unknown. Fix or restore "
|
||||
"[cyan].specify/presets/.registry[/cyan] and retry."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if affected_presets:
|
||||
preset_list = ", ".join(sorted(affected_presets))
|
||||
console.print(
|
||||
f"[red]Error:[/red] Cannot change '{key}' command layout while "
|
||||
f"preset override(s) are installed: [bold]{preset_list}[/bold]."
|
||||
)
|
||||
console.print(
|
||||
"Preset artifacts cannot yet be reconciled across a command↔skills "
|
||||
"layout change, so the migration would orphan their files and leave "
|
||||
"the preset registry inconsistent."
|
||||
)
|
||||
console.print(
|
||||
"Remove the preset(s), run the upgrade, then reinstall them:\n"
|
||||
f" [cyan]specify preset remove <id>[/cyan]\n"
|
||||
f" [cyan]specify integration upgrade {key} "
|
||||
f"--integration-options \"...\"[/cyan]\n"
|
||||
f" [cyan]specify preset add <id>[/cyan]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Ensure shared infrastructure is up to date; --force overwrites existing files.
|
||||
infra_integration = integration
|
||||
infra_key = key
|
||||
@@ -415,7 +554,8 @@ def integration_upgrade(
|
||||
selected_script,
|
||||
force=force,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
infra_integration, current, infra_key, infra_parsed
|
||||
infra_integration, current, infra_key, infra_parsed,
|
||||
project_root=project_root,
|
||||
),
|
||||
)
|
||||
if os.name != "nt":
|
||||
@@ -441,6 +581,7 @@ def integration_upgrade(
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
project_root=project_root,
|
||||
)
|
||||
if installed_key == key:
|
||||
try:
|
||||
@@ -448,7 +589,8 @@ def integration_upgrade(
|
||||
project_root,
|
||||
selected_script,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
integration, {"integration_settings": settings}, key, parsed_options
|
||||
integration, {"integration_settings": settings}, key, parsed_options,
|
||||
project_root=project_root,
|
||||
),
|
||||
force=force,
|
||||
refresh_managed=True,
|
||||
@@ -463,7 +605,12 @@ def integration_upgrade(
|
||||
new_manifest.save()
|
||||
_write_integration_json(project_root, installed_key, installed_keys, settings)
|
||||
if installed_key == key:
|
||||
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
|
||||
_update_init_options_for_integration(
|
||||
project_root,
|
||||
integration,
|
||||
script_type=selected_script,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
else:
|
||||
_refresh_init_options_speckit_version(project_root)
|
||||
except Exception as exc:
|
||||
@@ -487,7 +634,15 @@ def integration_upgrade(
|
||||
if stale_keys:
|
||||
stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup")
|
||||
stale_manifest._files = {k: old_files[k] for k in stale_keys}
|
||||
stale_removed, _ = stale_manifest.uninstall(project_root, force=True)
|
||||
# remove_manifest=False: this throwaway manifest shares ``key`` with the
|
||||
# real one just saved above (new_manifest.save()). Letting uninstall()
|
||||
# delete ``{key}.manifest.json`` would wipe the freshly-written manifest
|
||||
# whenever an upgrade shrinks the tracked file set (e.g. Bob migrating
|
||||
# from the legacy commands layout to skills), leaving the integration
|
||||
# untracked and un-upgradeable.
|
||||
stale_removed, _ = stale_manifest.uninstall(
|
||||
project_root, force=True, remove_manifest=False
|
||||
)
|
||||
if stale_removed:
|
||||
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
|
||||
|
||||
@@ -497,6 +652,55 @@ def integration_upgrade(
|
||||
# Done after the upgrade has fully settled (Phase 2 included) and outside
|
||||
# the try/except above so this best-effort step cannot affect upgrade
|
||||
# success.
|
||||
#
|
||||
# Layout-change reconciliation: a dual-mode agent (e.g. Bob) can flip
|
||||
# between the legacy commands layout and the skills layout across an
|
||||
# upgrade (``upgrade bob --integration-options "--skills"`` / reverse
|
||||
# ``--legacy-commands``). Phase 2 above only removes stale files tracked by
|
||||
# the *integration* manifest (core commands); extension artifacts are
|
||||
# tracked separately in the extension registry, so the old layout's
|
||||
# extension command/skill files would otherwise linger as orphans. When the
|
||||
# layout actually changed, first unregister the agent's extension artifacts
|
||||
# (removing old-layout files and clearing per-agent registry entries) so the
|
||||
# re-registration below recreates them in the new layout. ``upgrade``s that
|
||||
# don't change layout skip this to avoid needless remove/re-add churn.
|
||||
#
|
||||
# Only the *active* integration is reconciled this way (``installed_key ==
|
||||
# key``). ``ExtensionManager.unregister_agent_artifacts`` treats the
|
||||
# per-extension ``registered_skills`` list as belonging to the passed agent
|
||||
# and, when that agent's skills directory is absent, falls back to scanning
|
||||
# every agent's skills directory — so running it for a *secondary*
|
||||
# (non-active) agent could delete or untrack the *active* agent's extension
|
||||
# skills. The subsequent re-registration cannot repair that because
|
||||
# extension skill rendering is intentionally scoped to the active agent
|
||||
# (#2948). Extension skills only ever exist for the active agent, so
|
||||
# skipping the unregister for a secondary agent orphans nothing new: a
|
||||
# secondary agent only has extension *command* files, which the
|
||||
# re-registration below rewrites in place regardless of layout.
|
||||
#
|
||||
# Known limitation: preset command/skill artifacts are NOT reconciled on a
|
||||
# layout change. There is no agent-scoped preset re-registration mechanism
|
||||
# anywhere in the CLI — ``use`` / ``switch`` / ``upgrade`` never reconcile
|
||||
# presets for any agent (presets are only (un)registered at preset
|
||||
# install/remove time). Rather than silently orphan them, the guard near
|
||||
# the top of this function rejects a layout-changing upgrade while preset
|
||||
# overrides are installed, so control only reaches here (with a changed
|
||||
# layout) when no preset artifacts are at stake. Full preset reconciliation
|
||||
# would require a new cross-cutting PresetManager subsystem affecting every
|
||||
# dual-layout agent, which is out of scope for this Bob migration.
|
||||
if (
|
||||
installed_key == key
|
||||
and _manifest_tracks_skill_layout(old_manifest)
|
||||
!= _manifest_tracks_skill_layout(new_manifest)
|
||||
):
|
||||
_unregister_extensions_for_agent(
|
||||
project_root,
|
||||
key,
|
||||
continuing=(
|
||||
"The integration layout changed, but old-layout extension "
|
||||
"artifacts may need manual cleanup."
|
||||
),
|
||||
)
|
||||
_register_extensions_for_agent(
|
||||
project_root,
|
||||
key,
|
||||
|
||||
@@ -14,6 +14,7 @@ Provides:
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
@@ -160,17 +161,66 @@ class IntegrationBase(ABC):
|
||||
return []
|
||||
|
||||
def effective_invoke_separator(
|
||||
self, parsed_options: dict[str, Any] | None = None
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
) -> str:
|
||||
"""Return the invoke separator for the given options.
|
||||
|
||||
Subclasses whose separator depends on runtime options (e.g.
|
||||
Copilot in ``--skills`` mode) should override this method.
|
||||
The default implementation ignores *parsed_options* and returns
|
||||
the class-level ``invoke_separator``.
|
||||
The default implementation ignores *parsed_options* and
|
||||
*project_root* and returns the class-level ``invoke_separator``.
|
||||
"""
|
||||
return self.invoke_separator
|
||||
|
||||
def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
|
||||
"""Command-ref separator given the project's *resolved* skills state.
|
||||
|
||||
Registration paths (extension / preset command rendering) have no CLI
|
||||
``parsed_options`` — only the persisted ``ai_skills`` flag — so they
|
||||
resolve the command-reference separator through this hook rather than
|
||||
the static ``AGENT_CONFIGS[key]["invoke_separator"]`` value, which
|
||||
cannot represent an agent whose separator differs between its skills
|
||||
and command layouts.
|
||||
|
||||
The default is mode-independent and returns exactly what
|
||||
``_build_agent_configs`` would place in ``AGENT_CONFIGS`` (the
|
||||
``registrar_config`` override if present, else the class-level
|
||||
``invoke_separator``), so single-layout agents are unaffected.
|
||||
Dual-mode agents whose separator depends on the layout (e.g. Bob:
|
||||
``-`` for skills, ``.`` for legacy commands) override this.
|
||||
"""
|
||||
cfg = self.registrar_config or {}
|
||||
return cfg.get("invoke_separator", self.invoke_separator)
|
||||
|
||||
def is_skills_mode(
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
) -> bool:
|
||||
"""Return whether this integration scaffolds skills for these options.
|
||||
|
||||
This is the single, well-defined hook the shared init/install/upgrade
|
||||
machinery consults to decide whether to persist ``ai_skills=True`` and
|
||||
render skill invocations. It replaces ad-hoc ``isinstance`` /
|
||||
``getattr(self, "_skills_mode", ...)`` probing so an integration's
|
||||
internal representation never has to leak into shared dispatch code.
|
||||
|
||||
*project_root* is optional context for the ``use`` / ``switch`` /
|
||||
``upgrade`` path, where no ``setup()`` runs and *parsed_options* may be
|
||||
empty: dual-mode integrations can consult the already-installed
|
||||
on-disk layout to avoid silently migrating an existing project to a
|
||||
different mode. The default ignores it.
|
||||
|
||||
The default (command-first integrations, e.g. Copilot's default
|
||||
layout) is skills mode only when ``--skills`` was requested.
|
||||
``SkillsIntegration`` overrides this to return ``True`` by default;
|
||||
skills-first integrations that expose a legacy opt-out (e.g. Bob)
|
||||
override it to honor their own flag.
|
||||
"""
|
||||
return bool((parsed_options or {}).get("skills"))
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -619,6 +669,46 @@ class IntegrationBase(ABC):
|
||||
return name
|
||||
return sys.executable or "python3"
|
||||
|
||||
@staticmethod
|
||||
def build_python_invocation(
|
||||
script_command: str, project_root: Path | None = None
|
||||
) -> str:
|
||||
"""Build a Python script command for the current platform shell."""
|
||||
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
|
||||
if os.name == "nt" and not re.fullmatch(r"[A-Za-z0-9_./:\\-]+", interpreter):
|
||||
quoted_interpreter = interpreter.replace("'", "''")
|
||||
interpreter = f"& '{quoted_interpreter}'"
|
||||
elif os.name != "nt":
|
||||
interpreter = shlex.quote(interpreter)
|
||||
return f"{interpreter} {script_command}"
|
||||
|
||||
@staticmethod
|
||||
def select_script_variant(
|
||||
requested: object, script_commands: dict[str, str]
|
||||
) -> str:
|
||||
"""Select the requested variant or a runnable platform fallback."""
|
||||
if isinstance(requested, str) and requested in script_commands:
|
||||
return requested
|
||||
|
||||
platform_variant = (
|
||||
"ps" if platform.system().lower().startswith("win") else "sh"
|
||||
)
|
||||
secondary_variant = "sh" if platform_variant == "ps" else "ps"
|
||||
fallbacks = (
|
||||
(platform_variant, "py")
|
||||
if requested == "py"
|
||||
else (platform_variant, secondary_variant, "py")
|
||||
)
|
||||
for candidate in fallbacks:
|
||||
if candidate in script_commands:
|
||||
return candidate
|
||||
|
||||
available = ", ".join(sorted(script_commands)) or "none"
|
||||
raise ValueError(
|
||||
"No runnable script variant for this platform: "
|
||||
f"requested {requested!r}; available: {available}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _interpreter_runs(path: str) -> bool:
|
||||
"""Return True when *path* executes as a Python interpreter.
|
||||
@@ -653,7 +743,8 @@ class IntegrationBase(ABC):
|
||||
"""Process a raw command template into agent-ready content.
|
||||
|
||||
Performs the same transformations as the release script:
|
||||
1. Extract ``scripts.<script_type>`` value from YAML frontmatter
|
||||
1. Select ``scripts.<script_type>`` from YAML frontmatter, falling
|
||||
back to a runnable platform shell or Python variant when unavailable
|
||||
2. Replace ``{SCRIPT}`` with the extracted script command
|
||||
3. Strip ``scripts:`` section from frontmatter
|
||||
4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder*
|
||||
@@ -662,37 +753,46 @@ class IntegrationBase(ABC):
|
||||
7. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
|
||||
"""
|
||||
# 1. Extract script command from frontmatter
|
||||
script_command = ""
|
||||
script_pattern = re.compile(
|
||||
rf"^\s*{re.escape(script_type)}:\s*(.+)$", re.MULTILINE
|
||||
)
|
||||
script_commands: dict[str, str] = {}
|
||||
script_pattern = re.compile(r"^\s*([A-Za-z0-9_-]+):\s*(.+)$")
|
||||
# Find the scripts: block
|
||||
in_frontmatter = False
|
||||
in_scripts = False
|
||||
for line in content.splitlines():
|
||||
if line.strip() == "scripts:":
|
||||
if line == "---":
|
||||
if in_frontmatter:
|
||||
break
|
||||
in_frontmatter = True
|
||||
continue
|
||||
if not in_frontmatter:
|
||||
continue
|
||||
if line == "scripts:":
|
||||
in_scripts = True
|
||||
continue
|
||||
if in_scripts and line and not line[0].isspace():
|
||||
in_scripts = False
|
||||
break
|
||||
if in_scripts:
|
||||
m = script_pattern.match(line)
|
||||
if m:
|
||||
script_command = m.group(1).strip()
|
||||
break
|
||||
script_commands[m.group(1)] = m.group(2).strip()
|
||||
|
||||
selected_script_type = (
|
||||
IntegrationBase.select_script_variant(script_type, script_commands)
|
||||
if script_commands
|
||||
else ""
|
||||
)
|
||||
|
||||
script_command = script_commands.get(selected_script_type, "")
|
||||
|
||||
# 2. Replace {SCRIPT}
|
||||
if script_command:
|
||||
# For the Python script type, prefix the resolved interpreter so
|
||||
# the command is portable (``.py`` files are not directly
|
||||
# executable on Windows).
|
||||
if script_type == "py":
|
||||
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
|
||||
# Quote the interpreter if it contains whitespace (e.g. an
|
||||
# absolute ``sys.executable`` path under Windows
|
||||
# ``Program Files``) so it isn't split into multiple args.
|
||||
if any(ch.isspace() for ch in interpreter):
|
||||
interpreter = f'"{interpreter}"'
|
||||
script_command = f"{interpreter} {script_command}"
|
||||
if selected_script_type == "py":
|
||||
script_command = IntegrationBase.build_python_invocation(
|
||||
script_command, project_root
|
||||
)
|
||||
content = content.replace("{SCRIPT}", script_command)
|
||||
|
||||
# 3. Strip scripts: section from frontmatter
|
||||
@@ -1376,6 +1476,14 @@ class SkillsIntegration(IntegrationBase):
|
||||
|
||||
invoke_separator = "-"
|
||||
|
||||
def is_skills_mode(
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
) -> bool:
|
||||
"""Skills-native integrations scaffold skills unconditionally."""
|
||||
return True
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
|
||||
@@ -1,10 +1,140 @@
|
||||
"""IBM Bob integration."""
|
||||
"""IBM Bob integration.
|
||||
|
||||
from ..base import MarkdownIntegration
|
||||
Bob 2.0 uses the ``.bob/skills/speckit-<name>/SKILL.md`` layout by default.
|
||||
The legacy ``.bob/commands/*.md`` layout (Bob 1.x) remains available as an
|
||||
opt-in via ``--integration-options "--legacy-commands"``.
|
||||
|
||||
Bob is a *dual-mode* integration: whether it scaffolds skills or commands is
|
||||
a per-project **configuration** decision (the ``--legacy-commands`` option,
|
||||
persisted as ``ai_skills`` in init-options), not a property of the class.
|
||||
It therefore extends :class:`IntegrationBase` (like Copilot, the other
|
||||
dual-mode agent) and resolves the mode through the ``is_skills_mode`` hook,
|
||||
delegating the actual scaffolding to a per-layout helper.
|
||||
|
||||
Deprecation cycle:
|
||||
This release: Skills layout is the default; legacy ``.bob/commands/`` is
|
||||
opt-in via ``--legacy-commands``.
|
||||
Next cycle: ``--legacy-commands`` flag removed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
|
||||
from ..base import (
|
||||
IntegrationBase,
|
||||
IntegrationOption,
|
||||
MarkdownIntegration,
|
||||
SkillsIntegration,
|
||||
)
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
class BobIntegration(MarkdownIntegration):
|
||||
def _validate_mode_options(parsed_options: dict[str, Any] | None) -> None:
|
||||
"""Reject ``--skills`` and ``--legacy-commands`` used together.
|
||||
|
||||
The two flags select opposite layouts, so combining them is ambiguous.
|
||||
Fail fast with the same clean exit-1 UX as other bad-option paths rather
|
||||
than silently letting one win.
|
||||
"""
|
||||
opts = parsed_options or {}
|
||||
if opts.get("skills") and opts.get("legacy_commands"):
|
||||
from ..._console import console
|
||||
|
||||
console.print(
|
||||
"[red]Error:[/red] --skills and --legacy-commands are mutually "
|
||||
"exclusive; pass only one."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _warn_legacy_commands_deprecated() -> None:
|
||||
warnings.warn(
|
||||
"Bob legacy commands mode (.bob/commands/) is deprecated and will be "
|
||||
"removed in a future Spec Kit release. Omit --legacy-commands to use "
|
||||
"the default skills layout (.bob/skills/).",
|
||||
UserWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
|
||||
|
||||
class _BobSkillsHelper(SkillsIntegration):
|
||||
"""Default-mode helper: ``.bob/skills/speckit-<name>/SKILL.md``.
|
||||
|
||||
Not registered in the integration registry — used only as a delegate by
|
||||
:class:`BobIntegration` for skills-mode ``setup()``.
|
||||
"""
|
||||
|
||||
key = "bob"
|
||||
config = {
|
||||
"name": "IBM Bob",
|
||||
"folder": ".bob/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".bob/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Bob skills are intent-activated; no slash-command note is needed."""
|
||||
return content
|
||||
|
||||
|
||||
class _BobMarkdownHelper(MarkdownIntegration):
|
||||
"""Legacy-mode helper: ``.bob/commands/speckit.<name>.md`` (Bob 1.x).
|
||||
|
||||
Not registered in the integration registry — used only as a delegate by
|
||||
:class:`BobIntegration` when ``--legacy-commands`` is passed. Declares
|
||||
``invoke_separator="."`` so command-reference tokens render as Bob 1.x
|
||||
``/speckit.<name>`` invocations.
|
||||
"""
|
||||
|
||||
key = "bob"
|
||||
invoke_separator = "."
|
||||
config = {
|
||||
"name": "IBM Bob",
|
||||
"folder": ".bob/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".bob/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
"invoke_separator": ".",
|
||||
}
|
||||
|
||||
|
||||
class BobIntegration(IntegrationBase):
|
||||
"""Integration for IBM Bob IDE (dual-mode; skills by default).
|
||||
|
||||
Whether a project uses the skills or the legacy commands layout is a
|
||||
configuration choice resolved by :meth:`is_skills_mode`, not the class
|
||||
hierarchy. ``setup()`` delegates to the matching helper.
|
||||
|
||||
``registrar_config`` mirrors the *commands* layout (``extension: ".md"``,
|
||||
``dir: ".bob/commands"``) — the same pattern Copilot uses — so that
|
||||
``CommandRegistrar.AGENT_CONFIGS["bob"]`` drives extension/preset
|
||||
registration into ``.bob/commands/`` for legacy-mode projects, while
|
||||
skills-mode projects have that command registration transparently skipped
|
||||
(``skills_mode_active`` becomes ``True`` because ``ai_skills=True`` and
|
||||
``extension != "/SKILL.md"``) and receive extension skills instead.
|
||||
``invoke_separator = "-"`` matches the default (skills) layout.
|
||||
"""
|
||||
|
||||
key = "bob"
|
||||
invoke_separator = "-"
|
||||
config = {
|
||||
"name": "IBM Bob",
|
||||
"folder": ".bob/",
|
||||
@@ -18,3 +148,136 @@ class BobIntegration(MarkdownIntegration):
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help=(
|
||||
"Force the default skills layout (.bob/skills/), overriding "
|
||||
"on-disk auto-detection. Use this to migrate a legacy "
|
||||
"commands install to skills, e.g. "
|
||||
"`integration upgrade bob --integration-options \"--skills\"`"
|
||||
),
|
||||
),
|
||||
IntegrationOption(
|
||||
"--legacy-commands",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help=(
|
||||
"Scaffold commands as legacy .bob/commands/*.md files "
|
||||
"(Bob 1.x layout, deprecated) instead of the default "
|
||||
"skills layout"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
def is_skills_mode(
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
) -> bool:
|
||||
"""Bob is skills-first; ``--legacy-commands`` opts out.
|
||||
|
||||
Precedence:
|
||||
|
||||
1. Explicit ``--skills`` wins — it *forces* skills mode regardless of
|
||||
what is already on disk. This is the supported migration / opt-in
|
||||
path: ``integration upgrade bob --integration-options "--skills"``
|
||||
converts a legacy commands install to the skills layout (setup()
|
||||
scaffolds ``.bob/skills`` and the upgrade's stale-file pass removes
|
||||
the old ``.bob/commands`` files).
|
||||
2. Explicit ``--legacy-commands`` opts out to the Bob 1.x layout.
|
||||
3. Otherwise, when a *project_root* is supplied, the layout is inferred
|
||||
from **managed Spec Kit artifacts** (see below).
|
||||
4. A fresh project (no managed artifacts, no flags) defaults to skills.
|
||||
|
||||
The disk-detection fallback exists because on ``use`` / ``switch`` /
|
||||
``upgrade`` (without an explicit ``--skills`` / ``--legacy-commands``)
|
||||
*parsed_options* is typically empty: no flag was passed, and existing
|
||||
Bob 1.x installs never persisted a ``legacy_commands`` option to
|
||||
recover. This is independent of whether ``setup()`` runs — ``upgrade``
|
||||
*does* call :meth:`setup` (see ``_migrate_commands.integration_upgrade``),
|
||||
but it passes those same empty *parsed_options*, so without disk
|
||||
detection the mode would resolve to the skills default. Defaulting to
|
||||
skills there would rewrite such a project's ``ai_skills`` flag to
|
||||
``True`` even though it still only contains a command layout, silently
|
||||
switching its extension / command-reference handling. So the layout is
|
||||
inferred from managed Spec Kit artifacts, not the mere presence of a
|
||||
``.bob/skills/`` directory: a user may keep unrelated Bob 2 skills in
|
||||
``.bob/skills/`` while their Spec Kit commands still live in
|
||||
``.bob/commands/speckit.*.md``. We therefore treat the project as
|
||||
legacy (command) mode only when managed Spec Kit command files exist
|
||||
and no managed Spec Kit skills (``speckit-*`` skill dirs) do. Passing
|
||||
``--skills`` overrides this so users are never trapped in legacy mode.
|
||||
"""
|
||||
opts = parsed_options or {}
|
||||
_validate_mode_options(opts)
|
||||
if opts.get("skills", False):
|
||||
return True
|
||||
if opts.get("legacy_commands", False):
|
||||
return False
|
||||
if project_root is not None:
|
||||
bob_dir = Path(project_root) / ".bob"
|
||||
has_managed_skills = any((bob_dir / "skills").glob("speckit-*"))
|
||||
has_managed_commands = any((bob_dir / "commands").glob("speckit.*.md"))
|
||||
if has_managed_commands and not has_managed_skills:
|
||||
return False
|
||||
return True
|
||||
|
||||
def effective_invoke_separator(
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
) -> str:
|
||||
"""``"."`` for the legacy commands layout, ``"-"`` for skills.
|
||||
|
||||
*project_root* lets the ``use`` / ``switch`` / ``upgrade`` path — which
|
||||
refreshes shared infrastructure *before* persisting init-options —
|
||||
detect an already-installed legacy layout, so core command references
|
||||
are rendered with the correct separator instead of defaulting to the
|
||||
skills ``-``.
|
||||
"""
|
||||
return "-" if self.is_skills_mode(parsed_options, project_root) else "."
|
||||
|
||||
def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
|
||||
"""Resolve the command-ref separator from a project's persisted mode.
|
||||
|
||||
Skills projects render ``/speckit-<cmd>``; legacy command projects
|
||||
render Bob 1.x ``/speckit.<cmd>``. Extension/preset registration
|
||||
consults this (via the persisted ``ai_skills`` flag) so both layouts
|
||||
get the correct separator despite sharing one static ``AGENT_CONFIGS``
|
||||
entry.
|
||||
"""
|
||||
return "-" if skills_enabled else "."
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Bob skills are intent-activated; no slash-command note is injected.
|
||||
|
||||
Preset/extension skill generators call this on the *registered*
|
||||
``BobIntegration`` instance, not on :class:`_BobSkillsHelper`, so the
|
||||
no-op must be repeated here (delegating to the helper) — otherwise
|
||||
those paths would inherit ``IntegrationBase``'s default and inject
|
||||
``/speckit-*`` hook guidance that core Bob skills intentionally omit.
|
||||
"""
|
||||
return _BobSkillsHelper().post_process_skill_content(content)
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
parsed_options = parsed_options or {}
|
||||
if self.is_skills_mode(parsed_options, project_root):
|
||||
return _BobSkillsHelper().setup(
|
||||
project_root, manifest, parsed_options, **opts
|
||||
)
|
||||
_warn_legacy_commands_deprecated()
|
||||
return MarkdownIntegration.setup(
|
||||
_BobMarkdownHelper(), project_root, manifest, parsed_options, **opts
|
||||
)
|
||||
|
||||
@@ -429,7 +429,8 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
)
|
||||
try:
|
||||
normalized_priority = int(raw_priority)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: int(float("inf")) — a ``priority: .inf``.
|
||||
raise IntegrationValidationError(
|
||||
f"Invalid catalog entry at index {idx} in {config_path}: "
|
||||
f"'priority' must be an integer, got "
|
||||
@@ -537,7 +538,8 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
else:
|
||||
try:
|
||||
priority = int(raw_priority)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: int(float("inf")) — a ``priority: .inf``.
|
||||
priority = yaml_idx + 1
|
||||
priority_pairs.append((priority, yaml_idx))
|
||||
if not priority_pairs:
|
||||
|
||||
@@ -122,7 +122,9 @@ class CopilotIntegration(IntegrationBase):
|
||||
_skills_mode: bool = False
|
||||
|
||||
def effective_invoke_separator(
|
||||
self, parsed_options: dict[str, Any] | None = None
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
) -> str:
|
||||
"""Return ``"-"`` when skills mode is requested, ``"."`` otherwise."""
|
||||
if parsed_options and parsed_options.get("skills"):
|
||||
@@ -131,6 +133,33 @@ class CopilotIntegration(IntegrationBase):
|
||||
return "-"
|
||||
return self.invoke_separator
|
||||
|
||||
def is_skills_mode(
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
) -> bool:
|
||||
"""Copilot is skills mode when ``--skills`` was requested.
|
||||
|
||||
On the init path ``setup()`` has already recorded the choice in
|
||||
``self._skills_mode``; on the ``use``/``install`` path (where no
|
||||
``setup()`` runs) the signal comes from *parsed_options* (#3550), which
|
||||
round-trips because ``--skills`` is persisted in the stored options.
|
||||
"""
|
||||
if parsed_options and parsed_options.get("skills"):
|
||||
return True
|
||||
return self._skills_mode
|
||||
|
||||
def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
|
||||
"""Skills projects render ``/speckit-<cmd>``; default markdown ``.``.
|
||||
|
||||
Copilot is dual-layout, so — like Bob — the command-reference
|
||||
separator depends on the persisted ``ai_skills`` state rather than a
|
||||
single static value. This keeps preset/extension command refs in a
|
||||
Copilot skills project consistent with ``build_command_invocation``
|
||||
(which emits ``/speckit-<stem>``).
|
||||
"""
|
||||
return "-" if skills_enabled else self.invoke_separator
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
|
||||
@@ -91,6 +91,18 @@ class ForgeIntegration(MarkdownIntegration):
|
||||
}
|
||||
invoke_separator = "-"
|
||||
|
||||
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
||||
"""Forge installs hyphenated slash-commands (``/speckit-<name>``), so the
|
||||
dispatch invocation must match. The inherited MarkdownIntegration default
|
||||
builds the dotted ``/speckit.<name>``, which references a command Forge
|
||||
never registered. Reuse the same hyphenation as the installed frontmatter
|
||||
``name`` (see ``format_forge_command_name``), mirroring the skills agents.
|
||||
"""
|
||||
invocation = "/" + format_forge_command_name(command_name)
|
||||
if args:
|
||||
invocation = f"{invocation} {args}"
|
||||
return invocation
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
|
||||
@@ -327,12 +327,18 @@ class IntegrationManifest:
|
||||
project_root: Path | None = None,
|
||||
*,
|
||||
force: bool = False,
|
||||
remove_manifest: bool = True,
|
||||
) -> tuple[list[Path], list[Path]]:
|
||||
"""Remove tracked files whose hash still matches.
|
||||
|
||||
Parameters:
|
||||
project_root: Override for the project root.
|
||||
force: If ``True``, remove files even if modified.
|
||||
project_root: Override for the project root.
|
||||
force: If ``True``, remove files even if modified.
|
||||
remove_manifest: If ``True`` (default), also delete this
|
||||
integration's ``{key}.manifest.json``. Set ``False`` for
|
||||
*partial* cleanups (e.g. the upgrade stale-file pass, which
|
||||
builds a throwaway manifest over a subset of files) so the
|
||||
real, freshly-saved manifest for the same key is not destroyed.
|
||||
|
||||
Returns:
|
||||
``(removed, skipped)`` — absolute paths.
|
||||
@@ -393,7 +399,7 @@ class IntegrationManifest:
|
||||
|
||||
# Remove the manifest file itself
|
||||
manifest = root / ".specify" / "integrations" / f"{self.key}.manifest.json"
|
||||
if manifest.exists():
|
||||
if remove_manifest and manifest.exists():
|
||||
manifest.unlink()
|
||||
parent = manifest.parent
|
||||
while parent != root:
|
||||
|
||||
@@ -1171,7 +1171,7 @@ class PresetManager:
|
||||
selected_ai, fm, body, self.project_root
|
||||
)
|
||||
body = self._resolve_skill_command_refs(
|
||||
body, registrar, selected_ai
|
||||
body, registrar, selected_ai, self.project_root
|
||||
)
|
||||
from ..integrations import get_integration
|
||||
integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None
|
||||
@@ -1252,7 +1252,10 @@ class PresetManager:
|
||||
|
||||
@staticmethod
|
||||
def _resolve_skill_command_refs(
|
||||
body: str, registrar: "CommandRegistrar", selected_ai: str
|
||||
body: str,
|
||||
registrar: "CommandRegistrar",
|
||||
selected_ai: str,
|
||||
project_root: "Path | None" = None,
|
||||
) -> str:
|
||||
"""Render ``__SPECKIT_COMMAND_*__`` tokens in a skill body as invocations.
|
||||
|
||||
@@ -1261,10 +1264,30 @@ class PresetManager:
|
||||
slash-command invocation — ``/speckit-<cmd>`` for a ``-`` separator,
|
||||
``/speckit.<cmd>`` for ``.`` — the same rendering the command layer
|
||||
applies via ``CommandRegistrar.register_commands()``.
|
||||
|
||||
For dual-layout agents (e.g. Bob) the separator depends on the
|
||||
project's persisted skills state, so — when *project_root* is provided
|
||||
— the separator is resolved from the integration via
|
||||
``invoke_separator_for_mode`` rather than the single static
|
||||
``AGENT_CONFIGS`` value.
|
||||
"""
|
||||
separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get(
|
||||
"invoke_separator", "."
|
||||
)
|
||||
separator = None
|
||||
if project_root is not None and isinstance(selected_ai, str):
|
||||
try:
|
||||
from .. import load_init_options
|
||||
from ..integrations import get_integration
|
||||
|
||||
integration = get_integration(selected_ai)
|
||||
if integration is not None:
|
||||
separator = integration.invoke_separator_for_mode(
|
||||
is_ai_skills_enabled(load_init_options(project_root))
|
||||
)
|
||||
except Exception:
|
||||
separator = None
|
||||
if separator is None:
|
||||
separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get(
|
||||
"invoke_separator", "."
|
||||
)
|
||||
return IntegrationBase.resolve_command_refs(body, separator)
|
||||
|
||||
def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]:
|
||||
@@ -1445,7 +1468,7 @@ class PresetManager:
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root
|
||||
)
|
||||
body = self._resolve_skill_command_refs(body, registrar, selected_ai)
|
||||
body = self._resolve_skill_command_refs(body, registrar, selected_ai, self.project_root)
|
||||
|
||||
for target_skill_name in target_skill_names:
|
||||
skill_subdir = skills_dir / target_skill_name
|
||||
@@ -1540,7 +1563,7 @@ class PresetManager:
|
||||
selected_ai, frontmatter, body, self.project_root
|
||||
)
|
||||
body = self._resolve_skill_command_refs(
|
||||
body, registrar, selected_ai
|
||||
body, registrar, selected_ai, self.project_root
|
||||
)
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
@@ -1592,7 +1615,7 @@ class PresetManager:
|
||||
selected_ai, frontmatter, body, self.project_root
|
||||
)
|
||||
body = self._resolve_skill_command_refs(
|
||||
body, registrar, selected_ai
|
||||
body, registrar, selected_ai, self.project_root
|
||||
)
|
||||
|
||||
command_name = extension_restore["command_name"]
|
||||
@@ -2108,13 +2131,22 @@ class PresetCatalog:
|
||||
url: str,
|
||||
timeout: int = 10,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
redirect_validator=None,
|
||||
):
|
||||
"""Open a URL with provider-based auth, trying each configured provider.
|
||||
|
||||
Delegates to :func:`specify_cli.authentication.http.open_url`.
|
||||
*redirect_validator*, when provided, is invoked as ``(old_url, new_url)``
|
||||
before EACH redirect hop, so an HTTPS host guarantee can be enforced on
|
||||
every intermediate URL, not just the terminal one.
|
||||
"""
|
||||
from specify_cli.authentication.http import open_url
|
||||
return open_url(url, timeout, extra_headers=extra_headers)
|
||||
return open_url(
|
||||
url,
|
||||
timeout,
|
||||
extra_headers=extra_headers,
|
||||
redirect_validator=redirect_validator,
|
||||
)
|
||||
|
||||
def _resolve_github_release_asset_api_url(
|
||||
self,
|
||||
@@ -2235,7 +2267,10 @@ class PresetCatalog:
|
||||
)
|
||||
try:
|
||||
priority = int(raw_priority)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: int(float("inf")) — a YAML ``priority: .inf``
|
||||
# would otherwise escape as an uncaught traceback instead of the
|
||||
# clean validation error (mirrors catalogs.py).
|
||||
raise PresetValidationError(
|
||||
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
||||
f"expected integer, got {raw_priority!r}"
|
||||
@@ -2401,7 +2436,21 @@ class PresetCatalog:
|
||||
pass
|
||||
|
||||
try:
|
||||
with self._open_url(entry.url, timeout=10) as response:
|
||||
# Validate EVERY redirect hop (not just the terminal URL): an
|
||||
# https -> http -> attacker-controlled-https chain would pass a
|
||||
# final-URL-only check while the insecure intermediate hop lets a
|
||||
# network attacker rewrite the next redirect. redirect_validator runs
|
||||
# before each hop; the final geturl() check is retained as a
|
||||
# belt-and-braces guard. Mirrors bundler/services/adapters.py.
|
||||
def _validate_redirect(_old_url: str, new_url: str) -> None:
|
||||
self._validate_catalog_url(new_url)
|
||||
|
||||
with self._open_url(
|
||||
entry.url, timeout=10, redirect_validator=_validate_redirect
|
||||
) as response:
|
||||
final_url = response.geturl()
|
||||
if final_url != entry.url:
|
||||
self._validate_catalog_url(final_url)
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
self._validate_catalog_payload(catalog_data, entry.url)
|
||||
@@ -2552,7 +2601,18 @@ class PresetCatalog:
|
||||
pass
|
||||
|
||||
try:
|
||||
with self._open_url(catalog_url, timeout=10) as response:
|
||||
# Same redirect hardening as _fetch_single_catalog: validate every
|
||||
# redirect hop AND the final URL so this legacy single-catalog path
|
||||
# is not vulnerable to an HTTPS->HTTP redirected payload either.
|
||||
def _validate_redirect(_old_url: str, new_url: str) -> None:
|
||||
self._validate_catalog_url(new_url)
|
||||
|
||||
with self._open_url(
|
||||
catalog_url, timeout=10, redirect_validator=_validate_redirect
|
||||
) as response:
|
||||
final_url = response.geturl()
|
||||
if final_url != catalog_url:
|
||||
self._validate_catalog_url(final_url)
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
# Validate catalog structure. Reuses the same helper as
|
||||
@@ -2717,8 +2777,20 @@ class PresetCatalog:
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(download_url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
# A malformed authority (e.g. an unterminated IPv6 bracket
|
||||
# "https://[::1") makes urlparse / hostname access raise ValueError.
|
||||
# The download_url comes from catalog payload data, so surface a clean
|
||||
# PresetError rather than leaking a raw ValueError past the command
|
||||
# handler (which only catches PresetError). Mirrors catalogs (#3435)
|
||||
# and workflows/catalog.py (#3484).
|
||||
try:
|
||||
parsed = urlparse(download_url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
raise PresetError(
|
||||
f"Preset download URL is malformed: {download_url}"
|
||||
) from None
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (
|
||||
parsed.scheme == "http" and is_localhost
|
||||
):
|
||||
|
||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
||||
|
||||
import typer
|
||||
import yaml
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
from .._console import console
|
||||
|
||||
@@ -107,8 +108,6 @@ def preset_add(
|
||||
try:
|
||||
_parsed = _urlparse(from_url)
|
||||
except ValueError:
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -141,9 +140,7 @@ def preset_add(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
from rich.markup import escape as _esc
|
||||
|
||||
console.print(f"Installing preset from [cyan]{_esc(from_url)}[/cyan]...")
|
||||
console.print(f"Installing preset from [cyan]{_escape_markup(from_url)}[/cyan]...")
|
||||
import urllib.error
|
||||
import tempfile
|
||||
import shutil
|
||||
@@ -183,7 +180,7 @@ def preset_add(
|
||||
except TypeError:
|
||||
output.write(response.read())
|
||||
except urllib.error.URLError as e:
|
||||
console.print(f"[red]Error:[/red] Failed to download: {e}")
|
||||
console.print(f"[red]Error:[/red] Failed to download: {_escape_markup(str(e))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
||||
@@ -240,13 +237,13 @@ def preset_add(
|
||||
raise typer.Exit(1)
|
||||
|
||||
except PresetCompatibilityError as e:
|
||||
console.print(f"[red]Compatibility Error:[/red] {e}")
|
||||
console.print(f"[red]Compatibility Error:[/red] {_escape_markup(str(e))}")
|
||||
raise typer.Exit(1)
|
||||
except PresetValidationError as e:
|
||||
console.print(f"[red]Validation Error:[/red] {e}")
|
||||
console.print(f"[red]Validation Error:[/red] {_escape_markup(str(e))}")
|
||||
raise typer.Exit(1)
|
||||
except PresetError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@@ -288,7 +285,7 @@ def preset_search(
|
||||
try:
|
||||
results = catalog.search(query=query, tag=tag, author=author)
|
||||
except PresetError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not results:
|
||||
@@ -582,7 +579,7 @@ def preset_catalog_list():
|
||||
try:
|
||||
active_catalogs = catalog.get_active_catalogs()
|
||||
except PresetValidationError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n")
|
||||
@@ -647,7 +644,7 @@ def preset_catalog_add(
|
||||
try:
|
||||
tmp_catalog._validate_catalog_url(url)
|
||||
except PresetValidationError as e:
|
||||
console.print(f"[red]Error:[/red] {e}")
|
||||
console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config_path = specify_dir / "preset-catalogs.yml"
|
||||
@@ -658,7 +655,7 @@ def preset_catalog_add(
|
||||
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
except Exception as e:
|
||||
config_label = _display_project_path(project_root, config_path)
|
||||
console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}")
|
||||
console.print(f"[red]Error:[/red] Failed to read {_escape_markup(str(config_label))}: {_escape_markup(str(e))}")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
config = {}
|
||||
|
||||
@@ -402,8 +402,13 @@ def install_shared_infra(
|
||||
# Track every shared path the current bundle produces so we can detect
|
||||
# manifest entries the core no longer ships (stale-script cleanup, #3076).
|
||||
seen_rels: set[str] = set()
|
||||
scripts_scanned = False
|
||||
variant_dir = {"sh": "bash", "py": "python"}.get(script_type, "powershell")
|
||||
scanned_variant_dirs: set[str] = set()
|
||||
shell_variant = "powershell" if os.name == "nt" else "bash"
|
||||
variant_dirs = (
|
||||
("python", shell_variant)
|
||||
if script_type == "py"
|
||||
else ("bash" if script_type == "sh" else "powershell",)
|
||||
)
|
||||
|
||||
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
|
||||
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
|
||||
@@ -458,69 +463,69 @@ def install_shared_infra(
|
||||
if scripts_src.is_dir():
|
||||
dest_scripts = project_path / ".specify" / "scripts"
|
||||
if _ensure_or_bucket_dir(dest_scripts):
|
||||
variant_src = scripts_src / variant_dir
|
||||
if variant_src.is_dir():
|
||||
for variant_dir in variant_dirs:
|
||||
variant_src = scripts_src / variant_dir
|
||||
if not variant_src.is_dir():
|
||||
continue
|
||||
dest_variant = dest_scripts / variant_dir
|
||||
if _ensure_or_bucket_dir(dest_variant):
|
||||
for src_path in variant_src.rglob("*"):
|
||||
if not src_path.is_file():
|
||||
continue
|
||||
# Python bytecode caches are local artifacts, not
|
||||
# workflow scripts — never install them.
|
||||
if "__pycache__" in src_path.parts:
|
||||
continue
|
||||
# Mark scanned only once a real source file is seen. An
|
||||
# empty (or symlink-skipped) variant keeps this False, so
|
||||
# stale-cleanup is skipped — otherwise it would treat every
|
||||
# tracked script as obsolete and delete it. (The safety
|
||||
# hinge is this flag, not ``seen_rels``, which also holds
|
||||
# template paths populated later.)
|
||||
scripts_scanned = True
|
||||
if not _ensure_or_bucket_dir(dest_variant):
|
||||
continue
|
||||
for src_path in variant_src.rglob("*"):
|
||||
if not src_path.is_file():
|
||||
continue
|
||||
# Python bytecode caches are local artifacts, not
|
||||
# workflow scripts — never install them.
|
||||
if "__pycache__" in src_path.parts:
|
||||
continue
|
||||
# Mark scanned only once a real source file is seen. An
|
||||
# empty (or symlink-skipped) variant stays untracked, so
|
||||
# stale-cleanup cannot treat its managed scripts as obsolete.
|
||||
scanned_variant_dirs.add(variant_dir)
|
||||
|
||||
rel_path = src_path.relative_to(variant_src)
|
||||
dst_path = dest_variant / rel_path
|
||||
rel = dst_path.relative_to(project_path).as_posix()
|
||||
seen_rels.add(rel)
|
||||
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
|
||||
continue
|
||||
write, bucket = _decide_overwrite(rel, dst_path)
|
||||
if not write:
|
||||
if bucket == "preserved":
|
||||
preserved_user_files.append(rel)
|
||||
else:
|
||||
skipped_files.append(rel)
|
||||
# Record the existing-on-disk file in the manifest so a
|
||||
# fresh manifest run against an already-populated
|
||||
# ``.specify/`` tree does not silently drop it (#2107).
|
||||
# ``prior_hashes`` is the function-scope snapshot taken
|
||||
# at entry, so this membership check is O(1) and avoids
|
||||
# the repeated ``dict(self._files)`` copy that
|
||||
# ``manifest.files`` performs on every access.
|
||||
if dst_path.is_file() and rel not in prior_hashes:
|
||||
try:
|
||||
manifest.record_existing(rel, recovered=True)
|
||||
except (OSError, ValueError) as exc:
|
||||
# Tolerate races / permission issues / non-file
|
||||
# collisions so one weird path does not abort
|
||||
# the whole install.
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}"
|
||||
)
|
||||
continue
|
||||
rel_path = src_path.relative_to(variant_src)
|
||||
dst_path = dest_variant / rel_path
|
||||
rel = dst_path.relative_to(project_path).as_posix()
|
||||
seen_rels.add(rel)
|
||||
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
|
||||
continue
|
||||
write, bucket = _decide_overwrite(rel, dst_path)
|
||||
if not write:
|
||||
if bucket == "preserved":
|
||||
preserved_user_files.append(rel)
|
||||
else:
|
||||
skipped_files.append(rel)
|
||||
# Record the existing-on-disk file in the manifest so a
|
||||
# fresh manifest run against an already-populated
|
||||
# ``.specify/`` tree does not silently drop it (#2107).
|
||||
# ``prior_hashes`` is the function-scope snapshot taken
|
||||
# at entry, so this membership check is O(1) and avoids
|
||||
# the repeated ``dict(self._files)`` copy that
|
||||
# ``manifest.files`` performs on every access.
|
||||
if dst_path.is_file() and rel not in prior_hashes:
|
||||
try:
|
||||
manifest.record_existing(rel, recovered=True)
|
||||
except (OSError, ValueError) as exc:
|
||||
# Tolerate races / permission issues / non-file
|
||||
# collisions so one weird path does not abort
|
||||
# the whole install.
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}"
|
||||
)
|
||||
continue
|
||||
|
||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
||||
continue
|
||||
content = src_path.read_text(encoding="utf-8")
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
content = _resolve_dynamic_command_refs(content, invoke_separator)
|
||||
planned_copies.append(
|
||||
(
|
||||
dst_path,
|
||||
rel,
|
||||
content.encode("utf-8"),
|
||||
src_path.stat().st_mode & 0o777,
|
||||
)
|
||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
||||
continue
|
||||
content = src_path.read_text(encoding="utf-8")
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
content = _resolve_dynamic_command_refs(content, invoke_separator)
|
||||
planned_copies.append(
|
||||
(
|
||||
dst_path,
|
||||
rel,
|
||||
content.encode("utf-8"),
|
||||
src_path.stat().st_mode & 0o777,
|
||||
)
|
||||
)
|
||||
|
||||
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
|
||||
if templates_src.is_dir():
|
||||
@@ -618,14 +623,16 @@ def install_shared_infra(
|
||||
# agent-context extension. Left behind, such an orphan can crash when it
|
||||
# sources a refreshed ``common.sh`` (#3076). Only run when the script source
|
||||
# was actually scanned (so a missing/empty source never triggers mass
|
||||
# deletion), scoped to the active variant, and only for *managed* copies —
|
||||
# deletion), scoped to the selected variants, and only for *managed* copies —
|
||||
# a user-customized file (hash diverges), a symlink, or a recovered entry is
|
||||
# preserved by ``_is_managed``.
|
||||
if scripts_scanned:
|
||||
if scanned_variant_dirs:
|
||||
stale_removed: list[str] = []
|
||||
script_prefix = f".specify/scripts/{variant_dir}/"
|
||||
script_prefixes = tuple(
|
||||
f".specify/scripts/{variant_dir}/" for variant_dir in scanned_variant_dirs
|
||||
)
|
||||
for rel in list(prior_hashes):
|
||||
if rel in seen_rels or not rel.startswith(script_prefix):
|
||||
if rel in seen_rels or not rel.startswith(script_prefixes):
|
||||
continue
|
||||
# Guard corrupted/hand-edited manifest keys BEFORE any filesystem
|
||||
# access: absolute, ``..``, or (on Windows) drive-relative keys such
|
||||
|
||||
@@ -49,6 +49,13 @@ workflow_step_catalog_app = typer.Typer(
|
||||
)
|
||||
workflow_step_app.add_typer(workflow_step_catalog_app, name="catalog")
|
||||
|
||||
workflow_overlay_app = typer.Typer(
|
||||
name="overlay",
|
||||
help="Manage workflow overlays",
|
||||
add_completion=False,
|
||||
)
|
||||
workflow_app.add_typer(workflow_overlay_app, name="overlay")
|
||||
|
||||
|
||||
def _error_console(json_output: bool):
|
||||
"""Console for error text: stderr under ``--json`` so the JSON stdout
|
||||
@@ -192,6 +199,10 @@ def _reject_unsafe_workflow_storage(project_root: Path) -> None:
|
||||
project_root / ".specify" / "workflows" / "runs",
|
||||
".specify/workflows/runs",
|
||||
)
|
||||
_reject_unsafe_dir(
|
||||
project_root / ".specify" / "workflows" / "overlays",
|
||||
".specify/workflows/overlays",
|
||||
)
|
||||
|
||||
|
||||
def _scan_for_workflow_owner(parts: tuple[str, ...]) -> int | None:
|
||||
@@ -366,7 +377,7 @@ def _resolve_installed_workflow_ownership(
|
||||
|
||||
|
||||
_WORKFLOW_ID_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
_RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"runs", "steps"})
|
||||
_RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays", "runs", "steps"})
|
||||
|
||||
|
||||
def _reject_insecure_download_redirect(old_url: str, new_url: str) -> None:
|
||||
@@ -1262,14 +1273,18 @@ def workflow_status(
|
||||
engine = WorkflowEngine(project_root)
|
||||
|
||||
if run_id:
|
||||
# Route errors to stderr under --json so the stdout JSON stream stays
|
||||
# parseable (mirrors `workflow run`/`workflow resume`); both handlers
|
||||
# fire before the json_output branch below.
|
||||
err = _error_console(json_output)
|
||||
try:
|
||||
from .engine import RunState
|
||||
state = RunState.load(run_id, project_root)
|
||||
except FileNotFoundError:
|
||||
console.print(f"[red]Error:[/red] Run not found: {run_id}")
|
||||
err.print(f"[red]Error:[/red] Run not found: {run_id}")
|
||||
raise typer.Exit(1)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
|
||||
err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if json_output:
|
||||
@@ -1666,8 +1681,8 @@ def workflow_add(
|
||||
except OSError as cleanup_exc:
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] Could not remove temporary "
|
||||
f"download file {_escape_markup(str(tmp_path))}: "
|
||||
f"{_escape_markup(str(cleanup_exc))}"
|
||||
f"workflow download file: {_escape_markup(str(cleanup_exc))} "
|
||||
f"(path: {_escape_markup(str(tmp_path))})"
|
||||
)
|
||||
console.print(f"[red]Error:[/red] Failed to download workflow: {_escape_markup(str(exc))}")
|
||||
raise typer.Exit(1)
|
||||
@@ -1691,8 +1706,8 @@ def workflow_add(
|
||||
except OSError as exc:
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] Could not remove temporary "
|
||||
f"download file {_escape_markup(str(tmp_path))}: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
f"workflow download file: {_escape_markup(str(exc))} "
|
||||
f"(path: {_escape_markup(str(tmp_path))})"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -2382,6 +2397,9 @@ def workflow_info(
|
||||
# Local workflow definition not found on disk; fall back to
|
||||
# catalog/registry lookup below.
|
||||
pass
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error:[/red] Invalid workflow: {_escape_markup(str(exc))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if definition:
|
||||
console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})")
|
||||
@@ -3148,6 +3166,102 @@ def workflow_step_catalog_remove(
|
||||
console.print(f"[green]✓[/green] Step catalog source '{removed_name}' removed")
|
||||
|
||||
|
||||
@workflow_overlay_app.command("add")
|
||||
def workflow_overlay_add_cmd(
|
||||
source: Path = typer.Argument(..., help="Path to overlay YAML file"),
|
||||
priority: int = typer.Option(
|
||||
10,
|
||||
"--priority",
|
||||
help="Resolution priority (lower = higher precedence, default 10)",
|
||||
),
|
||||
):
|
||||
"""Add a project-local overlay for a workflow."""
|
||||
from .overlays._commands import workflow_overlay_add
|
||||
|
||||
project_root = _require_specify_project()
|
||||
if workflow_overlay_add(project_root, source, priority) is None:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@workflow_overlay_app.command("set-priority")
|
||||
def workflow_overlay_set_priority_cmd(
|
||||
workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
|
||||
overlay_id: str = typer.Argument(..., help="Overlay ID"),
|
||||
priority: int = typer.Argument(
|
||||
..., help="New priority (lower = higher precedence)"
|
||||
),
|
||||
):
|
||||
"""Set the priority of a project-local overlay."""
|
||||
from .overlays._commands import workflow_overlay_set_priority
|
||||
|
||||
project_root = _require_specify_project()
|
||||
if not workflow_overlay_set_priority(project_root, workflow_id, overlay_id, priority):
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@workflow_overlay_app.command("enable")
|
||||
def workflow_overlay_enable_cmd(
|
||||
workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
|
||||
overlay_id: str = typer.Argument(..., help="Overlay ID"),
|
||||
):
|
||||
"""Enable a project-local overlay."""
|
||||
from .overlays._commands import workflow_overlay_enable
|
||||
|
||||
project_root = _require_specify_project()
|
||||
if not workflow_overlay_enable(project_root, workflow_id, overlay_id):
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@workflow_overlay_app.command("disable")
|
||||
def workflow_overlay_disable_cmd(
|
||||
workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
|
||||
overlay_id: str = typer.Argument(..., help="Overlay ID"),
|
||||
):
|
||||
"""Disable a project-local overlay."""
|
||||
from .overlays._commands import workflow_overlay_disable
|
||||
|
||||
project_root = _require_specify_project()
|
||||
if not workflow_overlay_disable(project_root, workflow_id, overlay_id):
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@workflow_overlay_app.command("remove")
|
||||
def workflow_overlay_remove_cmd(
|
||||
workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
|
||||
overlay_id: str = typer.Argument(..., help="Overlay ID"),
|
||||
):
|
||||
"""Remove a project-local overlay."""
|
||||
from .overlays._commands import workflow_overlay_remove
|
||||
|
||||
project_root = _require_specify_project()
|
||||
if not workflow_overlay_remove(project_root, workflow_id, overlay_id):
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@workflow_overlay_app.command("list")
|
||||
def workflow_overlay_list_cmd(
|
||||
workflow_id: str = typer.Argument(..., help="Workflow ID"),
|
||||
):
|
||||
"""List overlays for a workflow."""
|
||||
from .overlays._commands import workflow_overlay_list
|
||||
|
||||
project_root = _require_specify_project()
|
||||
if workflow_overlay_list(project_root, workflow_id) is None:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@workflow_app.command("resolve")
|
||||
def workflow_resolve_cmd(
|
||||
workflow_id: str = typer.Argument(..., help="Workflow ID to resolve"),
|
||||
):
|
||||
"""Show layer attribution for a resolved workflow."""
|
||||
from .overlays._commands import workflow_resolve
|
||||
|
||||
project_root = _require_specify_project()
|
||||
if workflow_resolve(project_root, workflow_id) is None:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def register(app: typer.Typer) -> None:
|
||||
"""Attach the workflow command group to the root Typer app."""
|
||||
app.add_typer(workflow_app, name="workflow")
|
||||
|
||||
@@ -364,13 +364,24 @@ class WorkflowCatalog:
|
||||
if not url:
|
||||
continue
|
||||
self._validate_catalog_url(url)
|
||||
try:
|
||||
priority = int(item.get("priority", idx + 1))
|
||||
except (TypeError, ValueError):
|
||||
raw_priority = item.get("priority", idx + 1)
|
||||
# bool is an int subclass: int(True) == 1 would silently accept a
|
||||
# ``priority: true`` as priority 1. Reject it explicitly, mirroring
|
||||
# the base CatalogStackBase loader.
|
||||
if isinstance(raw_priority, bool):
|
||||
raise WorkflowValidationError(
|
||||
f"Invalid priority for catalog "
|
||||
f"'{item.get('name', idx + 1)}': "
|
||||
f"expected integer, got {item.get('priority')!r}"
|
||||
f"expected integer, got {raw_priority!r}"
|
||||
)
|
||||
try:
|
||||
priority = int(raw_priority)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: int(float("inf")) — a ``priority: .inf``.
|
||||
raise WorkflowValidationError(
|
||||
f"Invalid priority for catalog "
|
||||
f"'{item.get('name', idx + 1)}': "
|
||||
f"expected integer, got {raw_priority!r}"
|
||||
)
|
||||
raw_install = item.get("install_allowed", False)
|
||||
if isinstance(raw_install, str):
|
||||
@@ -685,7 +696,9 @@ class WorkflowCatalog:
|
||||
def _coerce_priority(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: int(float("inf")) — treat an uncoercible
|
||||
# existing priority as 0 rather than crashing 'catalog add'.
|
||||
return 0
|
||||
|
||||
max_priority = max(
|
||||
@@ -1007,13 +1020,23 @@ class StepCatalog:
|
||||
if not url:
|
||||
continue
|
||||
self._validate_catalog_url(url)
|
||||
try:
|
||||
priority = int(item.get("priority", idx + 1))
|
||||
except (TypeError, ValueError):
|
||||
raw_priority = item.get("priority", idx + 1)
|
||||
# bool is an int subclass: reject ``priority: true`` explicitly rather
|
||||
# than silently coercing it to 1 (mirrors CatalogStackBase).
|
||||
if isinstance(raw_priority, bool):
|
||||
raise StepValidationError(
|
||||
f"Invalid priority for catalog "
|
||||
f"'{item.get('name', idx + 1)}': "
|
||||
f"expected integer, got {item.get('priority')!r}"
|
||||
f"expected integer, got {raw_priority!r}"
|
||||
)
|
||||
try:
|
||||
priority = int(raw_priority)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: int(float("inf")) — a ``priority: .inf``.
|
||||
raise StepValidationError(
|
||||
f"Invalid priority for catalog "
|
||||
f"'{item.get('name', idx + 1)}': "
|
||||
f"expected integer, got {raw_priority!r}"
|
||||
)
|
||||
raw_install = item.get("install_allowed", False)
|
||||
if isinstance(raw_install, str):
|
||||
@@ -1314,7 +1337,9 @@ class StepCatalog:
|
||||
def _coerce_priority(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: int(float("inf")) — treat an uncoercible
|
||||
# existing priority as 0 rather than crashing 'catalog add'.
|
||||
return 0
|
||||
|
||||
max_priority = max(
|
||||
|
||||
@@ -79,7 +79,11 @@ class WorkflowDefinition:
|
||||
def from_yaml(cls, path: Path) -> WorkflowDefinition:
|
||||
"""Load a workflow definition from a YAML file."""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
try:
|
||||
data = yaml.safe_load(f)
|
||||
except yaml.YAMLError as exc:
|
||||
msg = f"Invalid YAML in {path}: {exc}"
|
||||
raise ValueError(msg) from exc
|
||||
if not isinstance(data, dict):
|
||||
msg = f"Workflow YAML must be a mapping, got {type(data).__name__}."
|
||||
raise ValueError(msg)
|
||||
@@ -88,7 +92,11 @@ class WorkflowDefinition:
|
||||
@classmethod
|
||||
def from_string(cls, content: str) -> WorkflowDefinition:
|
||||
"""Load a workflow definition from a YAML string."""
|
||||
data = yaml.safe_load(content)
|
||||
try:
|
||||
data = yaml.safe_load(content)
|
||||
except yaml.YAMLError as exc:
|
||||
msg = f"Invalid YAML: {exc}"
|
||||
raise ValueError(msg) from exc
|
||||
if not isinstance(data, dict):
|
||||
msg = f"Workflow YAML must be a mapping, got {type(data).__name__}."
|
||||
raise ValueError(msg)
|
||||
@@ -727,13 +735,24 @@ class WorkflowEngine:
|
||||
ValueError:
|
||||
If the workflow YAML is invalid.
|
||||
"""
|
||||
from .overlays import WorkflowResolver
|
||||
|
||||
path = Path(source).expanduser()
|
||||
|
||||
# Try as a direct file path first
|
||||
if path.suffix.lower() in (".yml", ".yaml") and path.is_file():
|
||||
return WorkflowDefinition.from_yaml(path)
|
||||
|
||||
# Try as an installed workflow ID
|
||||
# Try as an installed workflow ID, resolving any overlays.
|
||||
resolver = WorkflowResolver(self.project_root)
|
||||
try:
|
||||
return resolver.resolve(str(source))
|
||||
except FileNotFoundError:
|
||||
# Fall back to the direct workflow.yml path so callers still get
|
||||
# the original error when the workflow id is not installed.
|
||||
pass
|
||||
|
||||
# Legacy direct path check for workflows installed without registry entries.
|
||||
installed_path = (
|
||||
self.project_root
|
||||
/ ".specify"
|
||||
|
||||
95
src/specify_cli/workflows/overlays/__init__.py
Normal file
95
src/specify_cli/workflows/overlays/__init__.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Workflow overlay resolver — composes installed workflows from layers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ..engine import WorkflowDefinition
|
||||
from .composer import StepListComposer
|
||||
from .layer_sources import (
|
||||
BaseWorkflowSource,
|
||||
Layer,
|
||||
ProjectOverlaySource,
|
||||
)
|
||||
from .merge import ComposedStep
|
||||
from .schema import _RESERVED_WORKFLOW_IDS, _SAFE_ID_PATTERN
|
||||
|
||||
|
||||
def _validate_workflow_id(workflow_id: str) -> None:
|
||||
"""Reject workflow IDs that are unsafe as installed-storage path segments."""
|
||||
if (
|
||||
not isinstance(workflow_id, str)
|
||||
or not _SAFE_ID_PATTERN.fullmatch(workflow_id)
|
||||
or workflow_id in _RESERVED_WORKFLOW_IDS
|
||||
):
|
||||
raise ValueError(f"Invalid workflow ID: {workflow_id!r}")
|
||||
|
||||
|
||||
class WorkflowResolver:
|
||||
"""Resolves a workflow ID to its composed ``WorkflowDefinition``.
|
||||
|
||||
Collects layers from two tiers:
|
||||
- project-local overlays (``.specify/workflows/overlays/<id>/*.yml``)
|
||||
- the base workflow itself (``.specify/workflows/<id>/workflow.yml``)
|
||||
|
||||
Resolution is lower-wins: overlays with lower priority numbers are applied
|
||||
later and override earlier edits on the same anchors.
|
||||
"""
|
||||
|
||||
def __init__(self, project_root: Path) -> None:
|
||||
self.project_root = project_root
|
||||
self._sources = [
|
||||
ProjectOverlaySource(project_root),
|
||||
BaseWorkflowSource(project_root),
|
||||
]
|
||||
self._composer = StepListComposer()
|
||||
|
||||
def collect_all_layers(
|
||||
self, workflow_id: str, *, include_disabled: bool = False
|
||||
) -> list[Layer]:
|
||||
"""Collect overlays sorted by precedence, followed by the base layer.
|
||||
|
||||
Lower priority numbers win. Ties are sorted alphabetically by source,
|
||||
matching ``PresetRegistry.list_by_priority()``. The base workflow is a
|
||||
foundation rather than a precedence candidate, so it is kept separate.
|
||||
"""
|
||||
_validate_workflow_id(workflow_id)
|
||||
|
||||
all_layers: list[Layer] = []
|
||||
for source in self._sources:
|
||||
all_layers.extend(
|
||||
source.collect(workflow_id, include_disabled=include_disabled)
|
||||
)
|
||||
|
||||
overlays = [layer for layer in all_layers if layer.tier != "base"]
|
||||
base_layers = [layer for layer in all_layers if layer.tier == "base"]
|
||||
return (
|
||||
sorted(overlays, key=lambda layer: (layer.priority, layer.source))
|
||||
+ base_layers
|
||||
)
|
||||
|
||||
def resolve(self, workflow_id: str) -> WorkflowDefinition:
|
||||
"""Resolve a workflow ID to its composed definition.
|
||||
|
||||
This method composes layers but does not validate workflow semantics;
|
||||
callers should validate the returned definition when needed.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: if the workflow cannot be found.
|
||||
ValueError: if layer collection/composition fails.
|
||||
"""
|
||||
layers = self.collect_all_layers(workflow_id)
|
||||
definition, _ = self._composer.compose(layers)
|
||||
if definition is None:
|
||||
raise FileNotFoundError(f"Workflow not found: {workflow_id}")
|
||||
return definition
|
||||
|
||||
def resolve_with_layers(
|
||||
self, workflow_id: str
|
||||
) -> tuple[WorkflowDefinition, list[Layer], list[ComposedStep]]:
|
||||
"""Resolve a workflow and return its definition plus layer attribution."""
|
||||
layers = self.collect_all_layers(workflow_id)
|
||||
definition, attribution = self._composer.compose(layers)
|
||||
if definition is None:
|
||||
raise FileNotFoundError(f"Workflow not found: {workflow_id}")
|
||||
return definition, layers, attribution
|
||||
442
src/specify_cli/workflows/overlays/_commands.py
Normal file
442
src/specify_cli/workflows/overlays/_commands.py
Normal file
@@ -0,0 +1,442 @@
|
||||
"""CLI handlers for ``specify workflow overlay *`` and ``specify workflow resolve``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
import yaml
|
||||
|
||||
from ..._console import console, err_console
|
||||
from ...extensions import normalize_priority
|
||||
from .._commands import (
|
||||
_commit_workflow_file,
|
||||
_discard_committed_backup_file,
|
||||
_reject_unsafe_dir,
|
||||
_reject_unsafe_workflow_storage,
|
||||
_safe_discard_staged_workflow_file,
|
||||
_stage_workflow_file,
|
||||
)
|
||||
from . import WorkflowResolver
|
||||
from .schema import _RESERVED_WORKFLOW_IDS, _SAFE_ID_PATTERN, validate_overlay_yaml
|
||||
|
||||
|
||||
def _validate_overlay_id_or_exit(id_value: str, label: str) -> None:
|
||||
"""Validate a single-segment overlay/workflow id from CLI arguments."""
|
||||
if not isinstance(id_value, str) or not id_value:
|
||||
err_console.print(f"[red]Error:[/red] {label} is required and must be a non-empty string.")
|
||||
raise typer.Exit(1)
|
||||
if not _SAFE_ID_PATTERN.fullmatch(id_value):
|
||||
err_console.print(
|
||||
f"[red]Error:[/red] Invalid {label} {id_value!r}: "
|
||||
"only lowercase letters, digits, and hyphens are allowed."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _validate_workflow_id_or_exit(workflow_id: str) -> None:
|
||||
"""Validate a workflow id, treating the overlay root as reserved."""
|
||||
_validate_overlay_id_or_exit(workflow_id, "workflow ID")
|
||||
if workflow_id in _RESERVED_WORKFLOW_IDS:
|
||||
err_console.print(
|
||||
f"[red]Error:[/red] Invalid workflow ID {workflow_id!r}: "
|
||||
"reserved name."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _overlay_root(project_root: Path) -> Path:
|
||||
"""Return the project-local overlay root after rejecting unsafe ancestors."""
|
||||
_reject_unsafe_workflow_storage(project_root)
|
||||
root = project_root / ".specify" / "workflows" / "overlays"
|
||||
_reject_unsafe_dir(root, ".specify/workflows/overlays")
|
||||
return root
|
||||
|
||||
|
||||
def _project_overlay_dir(project_root: Path, workflow_id: str) -> Path:
|
||||
"""Return the project-local overlay directory for a workflow id.
|
||||
|
||||
Raises typer.Exit if the resolved path escapes the overlay root.
|
||||
"""
|
||||
_validate_workflow_id_or_exit(workflow_id)
|
||||
root = _overlay_root(project_root)
|
||||
target = root / workflow_id
|
||||
return _ensure_contained_dir(target, root)
|
||||
|
||||
|
||||
def _ensure_contained_dir(path: Path, root: Path) -> Path:
|
||||
"""Ensure *path* resolves inside *root* and is not a symlink.
|
||||
|
||||
Returns *path* if safe. Raises typer.Exit on traversal or symlink.
|
||||
"""
|
||||
_reject_unsafe_dir(root, ".specify/workflows/overlays")
|
||||
if path.is_symlink():
|
||||
err_console.print(
|
||||
f"[red]Error:[/red] Refusing to use symlinked path {path}."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if path.exists() and not path.is_dir():
|
||||
err_console.print(
|
||||
f"[red]Error:[/red] Overlay directory path is not a directory: {path}."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
root_resolved = root.resolve()
|
||||
resolved.relative_to(root_resolved)
|
||||
except ValueError:
|
||||
err_console.print(
|
||||
f"[red]Error:[/red] Path traversal detected: {path} is outside the allowed directory."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
return path
|
||||
|
||||
|
||||
def _find_overlay_file(project_root: Path, workflow_id: str, overlay_id: str) -> Path | None:
|
||||
"""Locate a project-local overlay file by its manifest ID, not filename.
|
||||
|
||||
Scans all YAML files in the overlay directory and matches on the ``id``
|
||||
field inside each manifest. This aligns with ``ProjectOverlaySource.collect()``
|
||||
which also derives identity from the manifest, not the filename.
|
||||
"""
|
||||
_validate_workflow_id_or_exit(workflow_id)
|
||||
_validate_overlay_id_or_exit(overlay_id, "overlay ID")
|
||||
overlay_dir = _project_overlay_dir(project_root, workflow_id)
|
||||
if not overlay_dir.is_dir():
|
||||
return None
|
||||
try:
|
||||
entries = sorted(overlay_dir.iterdir())
|
||||
except OSError:
|
||||
return None
|
||||
matches: list[Path] = []
|
||||
for path in entries:
|
||||
if not path.is_file() or path.suffix not in (".yml", ".yaml"):
|
||||
continue
|
||||
if path.is_symlink():
|
||||
continue
|
||||
data, _ = _read_overlay(path)
|
||||
if data is None:
|
||||
continue
|
||||
if data.get("id") == overlay_id:
|
||||
matches.append(path)
|
||||
if len(matches) > 1:
|
||||
paths = ", ".join(str(path) for path in matches)
|
||||
err_console.print(
|
||||
f"[red]Error:[/red] Duplicate overlay ID '{overlay_id}' in {paths}. "
|
||||
"Resolve the duplicate manifest IDs before continuing."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def _ensure_contained_path(path: Path, root: Path) -> Path:
|
||||
"""Return *path* only if it resolves inside *root*; otherwise raise typer.Exit."""
|
||||
_reject_unsafe_dir(root, ".specify/workflows/overlays")
|
||||
if path.is_symlink():
|
||||
err_console.print(
|
||||
f"[red]Error:[/red] Refusing to use symlinked path {path}."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
root_resolved = root.resolve()
|
||||
resolved.relative_to(root_resolved)
|
||||
except ValueError:
|
||||
err_console.print(
|
||||
f"[red]Error:[/red] Path traversal detected: {path} is outside the allowed directory."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
return path
|
||||
|
||||
|
||||
def _read_overlay(path: Path) -> tuple[dict[str, Any] | None, list[str]]:
|
||||
"""Read and parse an overlay YAML file, returning (data, errors)."""
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
return None, [f"Failed to read {path}: {exc}"]
|
||||
try:
|
||||
data = yaml.safe_load(content)
|
||||
except yaml.YAMLError as exc:
|
||||
return None, [f"Invalid YAML in {path}: {exc}"]
|
||||
if not isinstance(data, dict):
|
||||
return None, [f"Overlay {path} must be a YAML mapping."]
|
||||
return data, []
|
||||
|
||||
|
||||
def workflow_overlay_add(
|
||||
project_root: Path,
|
||||
source: Path,
|
||||
priority: int | None = None,
|
||||
) -> Path | None:
|
||||
"""Add a project-local overlay from a YAML file.
|
||||
|
||||
Returns the path of the installed overlay file, or None on failure.
|
||||
"""
|
||||
_reject_unsafe_workflow_storage(project_root)
|
||||
data, errors = _read_overlay(source)
|
||||
if data is None:
|
||||
for err in errors:
|
||||
err_console.print(f"[red]Error:[/red] {err}")
|
||||
return None
|
||||
|
||||
# Apply --priority override before validation so a valid CLI priority
|
||||
# can fix a missing or invalid priority in the file.
|
||||
if priority is not None:
|
||||
if isinstance(priority, bool) or not isinstance(priority, int) or priority < 1:
|
||||
err_console.print("[red]Error:[/red] Priority must be >= 1.")
|
||||
return None
|
||||
data["priority"] = normalize_priority(priority)
|
||||
|
||||
overlay, validation_errors = validate_overlay_yaml(data)
|
||||
if overlay is None:
|
||||
err_console.print("[red]Error:[/red] Overlay validation failed:")
|
||||
for err in validation_errors:
|
||||
err_console.print(f" \u2022 {err}")
|
||||
return None
|
||||
data["priority"] = overlay.priority
|
||||
|
||||
target_dir = _project_overlay_dir(project_root, overlay.extends)
|
||||
# Reuse an existing .yaml file so we don't create a duplicate .yml layer.
|
||||
existing = _find_overlay_file(project_root, overlay.extends, overlay.id)
|
||||
if existing is not None:
|
||||
target_path = existing
|
||||
else:
|
||||
target_path = _ensure_contained_path(
|
||||
target_dir / f"{overlay.id}.yml", _overlay_root(project_root)
|
||||
)
|
||||
|
||||
backup: Path | None = None
|
||||
try:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
existed_before = target_path.exists()
|
||||
staged = _stage_workflow_file(target_path.parent)
|
||||
try:
|
||||
staged.write_bytes(yaml.safe_dump(data, sort_keys=False).encode("utf-8"))
|
||||
backup = _commit_workflow_file(staged, target_path, existed_before)
|
||||
except BaseException:
|
||||
_safe_discard_staged_workflow_file(
|
||||
staged, target_path.parent, existed_before
|
||||
)
|
||||
raise
|
||||
except OSError as exc:
|
||||
err_console.print(f"[red]Error:[/red] Failed to write overlay: {exc}")
|
||||
return None
|
||||
_discard_committed_backup_file(backup)
|
||||
|
||||
console.print(
|
||||
f"[green]\u2713[/green] Overlay '{overlay.id}' added for workflow '{overlay.extends}'"
|
||||
)
|
||||
return target_path
|
||||
|
||||
|
||||
def _update_overlay_field(
|
||||
project_root: Path,
|
||||
workflow_id: str,
|
||||
overlay_id: str,
|
||||
field: str,
|
||||
value: Any,
|
||||
) -> bool:
|
||||
"""Update a single field in a project-local overlay file."""
|
||||
_reject_unsafe_workflow_storage(project_root)
|
||||
path = _find_overlay_file(project_root, workflow_id, overlay_id)
|
||||
if path is None:
|
||||
err_console.print(
|
||||
f"[red]Error:[/red] Overlay '{overlay_id}' not found for workflow '{workflow_id}'"
|
||||
)
|
||||
return False
|
||||
|
||||
data, errors = _read_overlay(path)
|
||||
if data is None:
|
||||
for err in errors:
|
||||
err_console.print(f"[red]Error:[/red] {err}")
|
||||
return False
|
||||
|
||||
data[field] = value
|
||||
overlay, validation_errors = validate_overlay_yaml(data)
|
||||
if overlay is None:
|
||||
err_console.print("[red]Error:[/red] Overlay validation failed:")
|
||||
for err in validation_errors:
|
||||
err_console.print(f" \u2022 {err}")
|
||||
return False
|
||||
|
||||
backup: Path | None = None
|
||||
try:
|
||||
existed_before = path.exists()
|
||||
staged = _stage_workflow_file(path.parent)
|
||||
try:
|
||||
staged.write_bytes(yaml.safe_dump(data, sort_keys=False).encode("utf-8"))
|
||||
backup = _commit_workflow_file(staged, path, existed_before)
|
||||
except BaseException:
|
||||
_safe_discard_staged_workflow_file(staged, path.parent, existed_before)
|
||||
raise
|
||||
except OSError as exc:
|
||||
err_console.print(f"[red]Error:[/red] Failed to write overlay: {exc}")
|
||||
return False
|
||||
_discard_committed_backup_file(backup)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def workflow_overlay_set_priority(
|
||||
project_root: Path,
|
||||
workflow_id: str,
|
||||
overlay_id: str,
|
||||
priority: int,
|
||||
) -> bool:
|
||||
"""Set the priority of a project-local overlay."""
|
||||
if isinstance(priority, bool) or not isinstance(priority, int) or priority < 1:
|
||||
err_console.print("[red]Error:[/red] Priority must be >= 1.")
|
||||
raise typer.Exit(1)
|
||||
normalized_priority = normalize_priority(priority)
|
||||
if _update_overlay_field(
|
||||
project_root, workflow_id, overlay_id, "priority", normalized_priority
|
||||
):
|
||||
console.print(
|
||||
f"[green]\u2713[/green] Priority of overlay '{overlay_id}' set to {normalized_priority}"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def workflow_overlay_enable(
|
||||
project_root: Path,
|
||||
workflow_id: str,
|
||||
overlay_id: str,
|
||||
) -> bool:
|
||||
"""Enable a project-local overlay."""
|
||||
if _update_overlay_field(project_root, workflow_id, overlay_id, "enabled", True):
|
||||
console.print(f"[green]\u2713[/green] Overlay '{overlay_id}' enabled")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def workflow_overlay_disable(
|
||||
project_root: Path,
|
||||
workflow_id: str,
|
||||
overlay_id: str,
|
||||
) -> bool:
|
||||
"""Disable a project-local overlay."""
|
||||
if _update_overlay_field(project_root, workflow_id, overlay_id, "enabled", False):
|
||||
console.print(f"[green]\u2713[/green] Overlay '{overlay_id}' disabled")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def workflow_overlay_remove(
|
||||
project_root: Path,
|
||||
workflow_id: str,
|
||||
overlay_id: str,
|
||||
) -> bool:
|
||||
"""Remove a project-local overlay file."""
|
||||
_reject_unsafe_workflow_storage(project_root)
|
||||
path = _find_overlay_file(project_root, workflow_id, overlay_id)
|
||||
if path is None:
|
||||
err_console.print(
|
||||
f"[red]Error:[/red] Overlay '{overlay_id}' not found for workflow '{workflow_id}'"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError as exc:
|
||||
err_console.print(f"[red]Error:[/red] Failed to remove overlay: {exc}")
|
||||
return False
|
||||
|
||||
console.print(f"[green]\u2713[/green] Overlay '{overlay_id}' removed")
|
||||
return True
|
||||
|
||||
|
||||
def workflow_overlay_list(project_root: Path, workflow_id: str) -> list[dict[str, Any]] | None:
|
||||
"""List all overlays for a workflow and print a summary table.
|
||||
|
||||
Returns the raw list data for machine-readable callers, or None on error.
|
||||
"""
|
||||
_reject_unsafe_workflow_storage(project_root)
|
||||
_validate_workflow_id_or_exit(workflow_id)
|
||||
resolver = WorkflowResolver(project_root)
|
||||
try:
|
||||
layers = resolver.collect_all_layers(workflow_id, include_disabled=True)
|
||||
except ValueError as exc:
|
||||
err_console.print(f"[red]Error:[/red] {exc}")
|
||||
return None
|
||||
overlays = [layer for layer in layers if layer.tier != "base"]
|
||||
|
||||
if not overlays:
|
||||
console.print(f"[yellow]No overlays found for workflow '{workflow_id}'.[/yellow]")
|
||||
return []
|
||||
|
||||
console.print(f"Overlays for workflow '{workflow_id}':")
|
||||
rows: list[dict[str, Any]] = []
|
||||
for layer in overlays:
|
||||
overlay = layer.content
|
||||
rows.append({
|
||||
"id": overlay.id,
|
||||
"source": layer.source,
|
||||
"tier": layer.tier,
|
||||
"priority": normalize_priority(overlay.priority),
|
||||
"enabled": overlay.enabled,
|
||||
"path": str(layer.path) if layer.path else None,
|
||||
})
|
||||
enabled_marker = "enabled" if overlay.enabled else "disabled"
|
||||
console.print(
|
||||
f" \u2022 {overlay.id} (priority={normalize_priority(overlay.priority)}, "
|
||||
f"source={layer.source}, {enabled_marker})"
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def workflow_resolve(project_root: Path, workflow_id: str) -> dict[str, Any] | None:
|
||||
"""Print layer attribution for a resolved workflow.
|
||||
|
||||
Returns a serializable attribution payload.
|
||||
"""
|
||||
_reject_unsafe_workflow_storage(project_root)
|
||||
_validate_workflow_id_or_exit(workflow_id)
|
||||
resolver = WorkflowResolver(project_root)
|
||||
try:
|
||||
definition, layers, attribution = resolver.resolve_with_layers(workflow_id)
|
||||
except FileNotFoundError:
|
||||
err_console.print(
|
||||
f"[red]Error:[/red] Workflow '{workflow_id}' not found"
|
||||
)
|
||||
return None
|
||||
except ValueError as exc:
|
||||
err_console.print(f"[red]Error:[/red] {exc}")
|
||||
return None
|
||||
|
||||
console.print(f"Resolved workflow '{workflow_id}':")
|
||||
console.print("Layers (highest precedence first):")
|
||||
for layer in layers:
|
||||
priority = (
|
||||
"n/a" if layer.tier == "base" else str(normalize_priority(layer.priority))
|
||||
)
|
||||
console.print(
|
||||
f" \u2022 [{layer.tier}] {layer.source} "
|
||||
f"(priority={priority})"
|
||||
)
|
||||
|
||||
console.print("Step attribution:")
|
||||
for composed in attribution:
|
||||
console.print(f" \u2022 {composed.step_id}: {composed.source}")
|
||||
|
||||
return {
|
||||
"workflow_id": workflow_id,
|
||||
"layers": [
|
||||
{
|
||||
"source": layer.source,
|
||||
"tier": layer.tier,
|
||||
"priority": (
|
||||
None
|
||||
if layer.tier == "base"
|
||||
else normalize_priority(layer.priority)
|
||||
),
|
||||
}
|
||||
for layer in layers
|
||||
],
|
||||
"attribution": [
|
||||
{"step_id": composed.step_id, "source": composed.source}
|
||||
for composed in attribution
|
||||
],
|
||||
}
|
||||
97
src/specify_cli/workflows/overlays/composer.py
Normal file
97
src/specify_cli/workflows/overlays/composer.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Workflow overlay composer — builds a WorkflowDefinition from layers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..engine import WorkflowDefinition
|
||||
from .layer_sources import Layer
|
||||
from .merge import OverlayLayer, merge_steps, validate_edits
|
||||
|
||||
|
||||
class StepListComposer:
|
||||
"""Compose a workflow from a base layer and overlay layers.
|
||||
|
||||
- The base layer (tier="base") provides the full step list.
|
||||
- Overlay layers provide edit operations.
|
||||
- Overlays are applied in merge order: highest priority number first,
|
||||
lowest last, so lower priority numbers win. Ties are applied by overlay
|
||||
ID, with the alphabetically last ID winning.
|
||||
- Returns a parsed WorkflowDefinition; callers must validate separately.
|
||||
"""
|
||||
|
||||
def compose(
|
||||
self, layers: list[Layer]
|
||||
) -> tuple[WorkflowDefinition | None, list]:
|
||||
"""Compose a ``WorkflowDefinition`` from the given layers.
|
||||
|
||||
Returns ``(None, [])`` when no base layer is present.
|
||||
"""
|
||||
base_layer: Layer | None = None
|
||||
overlay_layers: list[Layer] = []
|
||||
for layer in layers:
|
||||
if layer.tier == "base":
|
||||
base_layer = layer
|
||||
else:
|
||||
overlay_layers.append(layer)
|
||||
|
||||
if base_layer is None or base_layer.path is None:
|
||||
return None, []
|
||||
|
||||
# Read the base workflow definition from disk.
|
||||
base_definition = WorkflowDefinition.from_yaml(base_layer.path)
|
||||
base_steps = base_definition.data.get("steps", [])
|
||||
if not isinstance(base_steps, list):
|
||||
# Preserve the invalid definition intact so validate_workflow can
|
||||
# report "'steps' must be a list." to the caller; coercing to []
|
||||
# here would mask that error.
|
||||
return base_definition, []
|
||||
|
||||
# Last applied wins, so apply lower priority numbers last.
|
||||
merge_order = sorted(
|
||||
overlay_layers,
|
||||
key=lambda layer: (-layer.priority, layer.content.id),
|
||||
)
|
||||
|
||||
# Validate edits against base anchors before mutation.
|
||||
base_step_ids = self._collect_base_step_ids(base_steps)
|
||||
for layer in merge_order:
|
||||
edit_errors = validate_edits(layer.content.edits, base_step_ids)
|
||||
if edit_errors:
|
||||
raise ValueError(
|
||||
f"Overlay '{layer.content.id}' has invalid edits:\n - "
|
||||
+ "\n - ".join(edit_errors)
|
||||
)
|
||||
|
||||
composed_steps, attribution = merge_steps(
|
||||
base_steps,
|
||||
[OverlayLayer(layer.content, layer.source) for layer in merge_order],
|
||||
)
|
||||
|
||||
# Build composed data while preserving all non-step fields from base.
|
||||
composed_data: dict[str, Any] = dict(base_definition.data)
|
||||
composed_data["steps"] = composed_steps
|
||||
|
||||
composed_definition = WorkflowDefinition(composed_data, source_path=base_layer.path)
|
||||
|
||||
return composed_definition, attribution
|
||||
|
||||
def _collect_base_step_ids(self, steps: list[dict[str, Any]]) -> set[str]:
|
||||
"""Collect all base step IDs reachable in the step tree."""
|
||||
ids: set[str] = set()
|
||||
for step in steps:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
step_id = step.get("id")
|
||||
if isinstance(step_id, str):
|
||||
ids.add(step_id)
|
||||
for key in ("then", "else", "steps", "default"):
|
||||
nested = step.get(key)
|
||||
if isinstance(nested, list):
|
||||
ids.update(self._collect_base_step_ids(nested))
|
||||
cases = step.get("cases")
|
||||
if isinstance(cases, dict):
|
||||
for case_steps in cases.values():
|
||||
if isinstance(case_steps, list):
|
||||
ids.update(self._collect_base_step_ids(case_steps))
|
||||
return ids
|
||||
234
src/specify_cli/workflows/overlays/layer_sources.py
Normal file
234
src/specify_cli/workflows/overlays/layer_sources.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""Workflow overlay layer sources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from .schema import Overlay, _RESERVED_WORKFLOW_IDS, _SAFE_ID_PATTERN, validate_overlay_yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class Layer:
|
||||
"""A single layer in the workflow overlay stack."""
|
||||
|
||||
content: Overlay
|
||||
source: str
|
||||
tier: str
|
||||
priority: int
|
||||
path: Path | None = None
|
||||
|
||||
|
||||
class OverlayLoadError(ValueError):
|
||||
"""Raised when an overlay file cannot be loaded or validated."""
|
||||
|
||||
def __init__(self, path: Path, errors: list[str]) -> None:
|
||||
self.path = path
|
||||
self.errors = errors
|
||||
super().__init__(f"Invalid overlay {path}:\n - " + "\n - ".join(errors))
|
||||
|
||||
|
||||
def _validate_workflow_id(workflow_id: str, context_path: Path) -> None:
|
||||
"""Raise OverlayLoadError if workflow_id is not a safe path-segment identifier.
|
||||
|
||||
Mirrors the same check performed by WorkflowResolver so layer sources are
|
||||
safe to call directly, without going through the resolver.
|
||||
"""
|
||||
if (
|
||||
not isinstance(workflow_id, str)
|
||||
or not _SAFE_ID_PATTERN.fullmatch(workflow_id)
|
||||
or workflow_id in _RESERVED_WORKFLOW_IDS
|
||||
):
|
||||
raise OverlayLoadError(
|
||||
context_path,
|
||||
[f"Invalid workflow ID: {workflow_id!r}"],
|
||||
)
|
||||
|
||||
|
||||
def _ensure_contained_dir(path: Path, root: Path) -> None:
|
||||
"""Raise OverlayLoadError if *path* is a symlink, a non-directory, or escapes *root*.
|
||||
|
||||
Mirrors the logic of ``_ensure_contained_dir`` in ``overlays/_commands.py``
|
||||
but raises ``OverlayLoadError`` instead of ``typer.Exit`` so layer sources
|
||||
can enforce the same invariants without a CLI dependency.
|
||||
|
||||
The caller is responsible for ensuring *root* itself is already validated
|
||||
(e.g. via ``_resolve_project_overlay_root``).
|
||||
"""
|
||||
if path.is_symlink():
|
||||
raise OverlayLoadError(path, ["Symlinked overlay directories are not allowed"])
|
||||
if path.exists() and not path.is_dir():
|
||||
raise OverlayLoadError(path, ["Overlay directory path is not a directory"])
|
||||
try:
|
||||
path.resolve().relative_to(root.resolve())
|
||||
except ValueError:
|
||||
raise OverlayLoadError(
|
||||
path, ["Path traversal detected: directory escapes allowed root"]
|
||||
) from None
|
||||
|
||||
|
||||
def _resolve_workflows_root(project_root: Path) -> Path:
|
||||
"""Return the workflow storage root after rejecting unsafe ancestors."""
|
||||
project_root_resolved = project_root.resolve()
|
||||
workflows_root = project_root / ".specify" / "workflows"
|
||||
|
||||
current = project_root
|
||||
for part in (".specify", "workflows"):
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
raise OverlayLoadError(
|
||||
current,
|
||||
[f"Symlinked workflow directories are not allowed ({current})"],
|
||||
)
|
||||
if current.exists() and not current.is_dir():
|
||||
raise OverlayLoadError(
|
||||
current,
|
||||
[f"Workflow directory path is not a directory ({current})"],
|
||||
)
|
||||
|
||||
try:
|
||||
workflows_root.resolve().relative_to(project_root_resolved)
|
||||
except ValueError:
|
||||
raise OverlayLoadError(
|
||||
workflows_root,
|
||||
["Workflow directory escapes the project root"],
|
||||
) from None
|
||||
return workflows_root
|
||||
|
||||
|
||||
def _resolve_project_overlay_root(project_root: Path) -> Path:
|
||||
"""Return the unresolved overlay root after rejecting unsafe ancestors."""
|
||||
workflows_root = _resolve_workflows_root(project_root)
|
||||
overlays_root = workflows_root / "overlays"
|
||||
if overlays_root.is_symlink():
|
||||
raise OverlayLoadError(
|
||||
overlays_root,
|
||||
[f"Symlinked overlay directories are not allowed ({overlays_root})"],
|
||||
)
|
||||
if overlays_root.exists() and not overlays_root.is_dir():
|
||||
raise OverlayLoadError(
|
||||
overlays_root,
|
||||
[f"Overlay directory path is not a directory ({overlays_root})"],
|
||||
)
|
||||
return overlays_root
|
||||
|
||||
|
||||
class ProjectOverlaySource:
|
||||
"""Project-local overlays: ``.specify/workflows/overlays/<id>/*.yml``."""
|
||||
|
||||
tier = "project-overlay"
|
||||
|
||||
def __init__(self, project_root: Path) -> None:
|
||||
self.project_root = project_root
|
||||
self.overlays_dir = project_root / ".specify" / "workflows" / "overlays"
|
||||
|
||||
def collect(self, workflow_id: str, *, include_disabled: bool = False) -> list[Layer]:
|
||||
"""Collect project-local overlays for the given workflow id.
|
||||
|
||||
Args:
|
||||
workflow_id: Workflow identifier whose overlay directory to scan.
|
||||
include_disabled: When True, return disabled overlays for
|
||||
management/list views. Resolution paths keep the default False.
|
||||
"""
|
||||
self.overlays_dir = _resolve_project_overlay_root(self.project_root)
|
||||
_validate_workflow_id(workflow_id, self.overlays_dir)
|
||||
workflow_overlay_dir = self.overlays_dir / workflow_id
|
||||
_ensure_contained_dir(workflow_overlay_dir, self.overlays_dir)
|
||||
if not workflow_overlay_dir.is_dir():
|
||||
return []
|
||||
layers: list[Layer] = []
|
||||
overlay_paths_by_id: dict[str, Path] = {}
|
||||
try:
|
||||
entries = sorted(workflow_overlay_dir.iterdir())
|
||||
except OSError as exc:
|
||||
raise OverlayLoadError(
|
||||
workflow_overlay_dir, [f"Cannot enumerate overlays: {exc}"]
|
||||
) from exc
|
||||
for path in entries:
|
||||
if not path.is_file() or path.suffix not in (".yml", ".yaml"):
|
||||
continue
|
||||
if path.is_symlink():
|
||||
raise OverlayLoadError(path, ["Symlinked overlay files are not allowed"])
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise OverlayLoadError(path, [f"Invalid YAML: {exc}"]) from exc
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise OverlayLoadError(path, [f"Cannot load overlay: {exc}"]) from exc
|
||||
if (
|
||||
not include_disabled
|
||||
and isinstance(data, dict)
|
||||
and data.get("enabled", True) is False
|
||||
):
|
||||
continue
|
||||
overlay, errors = validate_overlay_yaml(data)
|
||||
if overlay is None or errors:
|
||||
raise OverlayLoadError(path, errors)
|
||||
if overlay.extends != workflow_id:
|
||||
raise OverlayLoadError(
|
||||
path,
|
||||
[
|
||||
f"Overlay extends {overlay.extends!r}, but is stored under "
|
||||
f"workflow {workflow_id!r}."
|
||||
],
|
||||
)
|
||||
first_path = overlay_paths_by_id.get(overlay.id)
|
||||
if first_path is not None:
|
||||
raise OverlayLoadError(
|
||||
path,
|
||||
[
|
||||
f"Duplicate overlay id {overlay.id!r}; also declared in "
|
||||
f"{first_path}."
|
||||
],
|
||||
)
|
||||
overlay_paths_by_id[overlay.id] = path
|
||||
layers.append(
|
||||
Layer(
|
||||
content=overlay,
|
||||
source=f"project:{overlay.id}",
|
||||
tier=self.tier,
|
||||
priority=overlay.priority,
|
||||
path=path,
|
||||
)
|
||||
)
|
||||
return layers
|
||||
|
||||
|
||||
class BaseWorkflowSource:
|
||||
"""Base workflow layer: ``.specify/workflows/<id>/workflow.yml``."""
|
||||
|
||||
tier = "base"
|
||||
|
||||
def __init__(self, project_root: Path) -> None:
|
||||
self.project_root = project_root
|
||||
self.workflows_dir = project_root / ".specify" / "workflows"
|
||||
|
||||
def collect(self, workflow_id: str, *, include_disabled: bool = False) -> list[Layer]:
|
||||
"""Return the base workflow as a single layer if it exists."""
|
||||
self.workflows_dir = _resolve_workflows_root(self.project_root)
|
||||
_validate_workflow_id(workflow_id, self.workflows_dir)
|
||||
workflow_dir = self.workflows_dir / workflow_id
|
||||
_ensure_contained_dir(workflow_dir, self.workflows_dir)
|
||||
path = workflow_dir / "workflow.yml"
|
||||
if path.is_symlink():
|
||||
raise OverlayLoadError(path, ["Symlinked workflow files are not allowed"])
|
||||
if not path.is_file():
|
||||
return []
|
||||
# The base layer is represented by an Overlay with empty edits.
|
||||
overlay = Overlay(
|
||||
id=workflow_id,
|
||||
extends=workflow_id,
|
||||
priority=0,
|
||||
edits=[],
|
||||
)
|
||||
return [
|
||||
Layer(
|
||||
content=overlay,
|
||||
source="base",
|
||||
tier=self.tier,
|
||||
priority=0,
|
||||
path=path,
|
||||
)
|
||||
]
|
||||
395
src/specify_cli/workflows/overlays/merge.py
Normal file
395
src/specify_cli/workflows/overlays/merge.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""Pure-function merge engine for workflow step lists."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .schema import VALID_OPERATIONS, Overlay, OverlayEdit
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComposedStep:
|
||||
"""Attribution tracking for a single composed step."""
|
||||
|
||||
step_id: str
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OverlayLayer:
|
||||
"""An overlay together with its layer source for attribution."""
|
||||
|
||||
overlay: Overlay
|
||||
source: str
|
||||
|
||||
|
||||
# Nested step keys that may contain a list of steps.
|
||||
_NESTED_LIST_KEYS = ("then", "else", "steps", "default")
|
||||
|
||||
|
||||
def find_step(
|
||||
steps: list[dict[str, Any]], step_id: str
|
||||
) -> tuple[list[dict[str, Any]], int] | None:
|
||||
"""Recursively locate a step by ID and return its (parent_list, index).
|
||||
|
||||
Searches flat lists and nested lists inside ``then``, ``else``, ``steps``,
|
||||
``default``, and ``cases.*``. Does *not* descend into ``fan-out`` template
|
||||
steps because those are runtime-multiplied stamps, not uniquely-addressable
|
||||
nodes.
|
||||
"""
|
||||
for i, step in enumerate(steps):
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
if step.get("id") == step_id:
|
||||
return (steps, i)
|
||||
for key in _NESTED_LIST_KEYS:
|
||||
nested = step.get(key)
|
||||
if isinstance(nested, list):
|
||||
result = find_step(nested, step_id)
|
||||
if result is not None:
|
||||
return result
|
||||
cases = step.get("cases")
|
||||
if isinstance(cases, dict):
|
||||
for case_steps in cases.values():
|
||||
if isinstance(case_steps, list):
|
||||
result = find_step(case_steps, step_id)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def _all_base_step_ids(steps: list[dict[str, Any]]) -> set[str]:
|
||||
"""Collect all step IDs reachable in a step tree (excluding fan-out templates)."""
|
||||
ids: set[str] = set()
|
||||
for step in steps:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
step_id = step.get("id")
|
||||
if isinstance(step_id, str):
|
||||
ids.add(step_id)
|
||||
for key in _NESTED_LIST_KEYS:
|
||||
nested = step.get(key)
|
||||
if isinstance(nested, list):
|
||||
ids.update(_all_base_step_ids(nested))
|
||||
cases = step.get("cases")
|
||||
if isinstance(cases, dict):
|
||||
for case_steps in cases.values():
|
||||
if isinstance(case_steps, list):
|
||||
ids.update(_all_base_step_ids(case_steps))
|
||||
return ids
|
||||
|
||||
|
||||
def _descendant_ids(step: dict[str, Any]) -> set[str]:
|
||||
"""Return all step IDs nested inside *step* (not including *step* itself)."""
|
||||
ids: set[str] = set()
|
||||
for key in _NESTED_LIST_KEYS:
|
||||
nested = step.get(key)
|
||||
if isinstance(nested, list):
|
||||
ids.update(_all_base_step_ids(nested))
|
||||
cases = step.get("cases")
|
||||
if isinstance(cases, dict):
|
||||
for case_steps in cases.values():
|
||||
if isinstance(case_steps, list):
|
||||
ids.update(_all_base_step_ids(case_steps))
|
||||
return ids
|
||||
|
||||
|
||||
def _check_anchor_conflicts(
|
||||
anchor_operations: dict[str, str],
|
||||
base_steps: list[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
"""Return error messages for anchor pairs where one is an ancestor of the other.
|
||||
|
||||
Only flags conflicts where the ancestor's winning edit is ``replace`` or
|
||||
``remove`` — operations that destroy the subtree and make any descendant
|
||||
anchor unresolvable. Pure insert operations on an ancestor leave it intact,
|
||||
so its descendants remain reachable regardless of processing order.
|
||||
|
||||
Callers should raise on any returned errors before mutating the step tree.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
for anchor, operation in sorted(anchor_operations.items()):
|
||||
if operation in ("insert_after", "insert_before"):
|
||||
# Inserts leave the ancestor step intact; descendants are unaffected.
|
||||
continue
|
||||
location = find_step(base_steps, anchor)
|
||||
if location is None:
|
||||
continue # missing anchors are reported by validate_edits
|
||||
parent_list, idx = location
|
||||
step = parent_list[idx]
|
||||
conflicting = set(anchor_operations.keys()) & _descendant_ids(step)
|
||||
for child_anchor in sorted(conflicting):
|
||||
errors.append(
|
||||
f"Anchor conflict: '{anchor}' is an ancestor of '{child_anchor}'. "
|
||||
"Targeting both anchors in the same overlay set produces "
|
||||
"order-dependent results; restructure edits to avoid nesting."
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _init_sources_recursively(
|
||||
steps: list[dict[str, Any]], sources: dict[str, str]
|
||||
) -> None:
|
||||
"""Initialize attribution sources for all base steps, recursively."""
|
||||
for step in steps:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
step_id = step.get("id")
|
||||
if isinstance(step_id, str):
|
||||
sources[step_id] = "base"
|
||||
for key in _NESTED_LIST_KEYS:
|
||||
nested = step.get(key)
|
||||
if isinstance(nested, list):
|
||||
_init_sources_recursively(nested, sources)
|
||||
cases = step.get("cases")
|
||||
if isinstance(cases, dict):
|
||||
for case_steps in cases.values():
|
||||
if isinstance(case_steps, list):
|
||||
_init_sources_recursively(case_steps, sources)
|
||||
|
||||
|
||||
def _record_sources_recursively(
|
||||
step: dict[str, Any],
|
||||
source: str,
|
||||
sources: dict[str, str],
|
||||
) -> None:
|
||||
"""Record *source* for a step and all its nested child steps.
|
||||
|
||||
Traverses ``then``, ``else``, ``steps``, ``default``, and ``cases.*``
|
||||
so that ``workflow resolve`` attributes every step inside a composite
|
||||
insert or replacement to the correct overlay layer.
|
||||
"""
|
||||
step_id = step.get("id")
|
||||
if isinstance(step_id, str):
|
||||
sources[step_id] = source
|
||||
for key in _NESTED_LIST_KEYS:
|
||||
nested = step.get(key)
|
||||
if isinstance(nested, list):
|
||||
for child in nested:
|
||||
if isinstance(child, dict):
|
||||
_record_sources_recursively(child, source, sources)
|
||||
cases = step.get("cases")
|
||||
if isinstance(cases, dict):
|
||||
for case_steps in cases.values():
|
||||
if isinstance(case_steps, list):
|
||||
for child in case_steps:
|
||||
if isinstance(child, dict):
|
||||
_record_sources_recursively(child, source, sources)
|
||||
|
||||
|
||||
def _remove_sources_recursively(
|
||||
step: dict[str, Any],
|
||||
sources: dict[str, str],
|
||||
) -> None:
|
||||
"""Remove source entries for a step and all its nested child steps.
|
||||
|
||||
Traverses the same nesting keys as ``_record_sources_recursively``.
|
||||
"""
|
||||
step_id = step.get("id")
|
||||
if isinstance(step_id, str) and sources.get(step_id) == "base":
|
||||
sources.pop(step_id, None)
|
||||
for key in _NESTED_LIST_KEYS:
|
||||
nested = step.get(key)
|
||||
if isinstance(nested, list):
|
||||
for child in nested:
|
||||
if isinstance(child, dict):
|
||||
_remove_sources_recursively(child, sources)
|
||||
cases = step.get("cases")
|
||||
if isinstance(cases, dict):
|
||||
for case_steps in cases.values():
|
||||
if isinstance(case_steps, list):
|
||||
for child in case_steps:
|
||||
if isinstance(child, dict):
|
||||
_remove_sources_recursively(child, sources)
|
||||
|
||||
|
||||
|
||||
def _build_attribution(
|
||||
steps: list[dict[str, Any]],
|
||||
sources: dict[str, str],
|
||||
) -> list[ComposedStep]:
|
||||
"""Build an ordered attribution list from the composed step tree."""
|
||||
result: list[ComposedStep] = []
|
||||
for step in steps:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
step_id = step.get("id")
|
||||
if isinstance(step_id, str):
|
||||
result.append(ComposedStep(step_id, sources.get(step_id, "unknown")))
|
||||
for key in _NESTED_LIST_KEYS:
|
||||
nested = step.get(key)
|
||||
if isinstance(nested, list):
|
||||
result.extend(_build_attribution(nested, sources))
|
||||
cases = step.get("cases")
|
||||
if isinstance(cases, dict):
|
||||
for case_steps in cases.values():
|
||||
if isinstance(case_steps, list):
|
||||
result.extend(_build_attribution(case_steps, sources))
|
||||
return result
|
||||
|
||||
|
||||
def _traverse_and_apply(
|
||||
steps: list[dict[str, Any]],
|
||||
edits_by_anchor: dict[str, list[tuple[OverlayLayer, OverlayEdit]]],
|
||||
sources: dict[str, str],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Walk the original step tree and apply overlay edits as each step is encountered.
|
||||
|
||||
Edits are always resolved against the *original* structure — this function
|
||||
traverses the unmodified list passed in, so a replacement step's new ID can
|
||||
never be mistaken for a base anchor. Nested lists (``then``, ``else``, etc.)
|
||||
are recursed into only for steps that survive the edit (not for replaced
|
||||
steps).
|
||||
|
||||
*edits* are expected to be in merge order (lowest priority first, highest
|
||||
priority last); the winning edit for each anchor is ``edits[-1]``.
|
||||
"""
|
||||
result: list[dict[str, Any]] = []
|
||||
|
||||
for step in steps:
|
||||
if not isinstance(step, dict):
|
||||
result.append(step)
|
||||
continue
|
||||
|
||||
step_id = step.get("id")
|
||||
edits = edits_by_anchor.get(step_id, []) if isinstance(step_id, str) else []
|
||||
winning_edit = edits[-1][1] if edits else None
|
||||
|
||||
if winning_edit is not None and winning_edit.operation == "remove":
|
||||
# Winning edit removes this step; ignore all other edits on this anchor.
|
||||
# Do NOT call _remove_sources_recursively here: _build_attribution only
|
||||
# traverses the result list, so stale sources entries for removed steps
|
||||
# are never read. Calling it would incorrectly pop the attribution of a
|
||||
# *surviving* step that reuses the same ID (e.g. a replacement step
|
||||
# introduced by a higher-priority overlay targeting a different anchor).
|
||||
continue
|
||||
|
||||
# Insert before (in merge order).
|
||||
for layer, edit in edits:
|
||||
if edit.operation == "insert_before":
|
||||
new_step = copy.deepcopy(edit.step)
|
||||
_record_sources_recursively(new_step, layer.source, sources)
|
||||
result.append(new_step)
|
||||
|
||||
if winning_edit is not None and winning_edit.operation == "replace":
|
||||
winning_layer = edits[-1][0]
|
||||
new_step = copy.deepcopy(winning_edit.step)
|
||||
_remove_sources_recursively(step, sources)
|
||||
_record_sources_recursively(new_step, winning_layer.source, sources)
|
||||
result.append(new_step)
|
||||
else:
|
||||
# No replacement: keep this step and recurse into its nested lists.
|
||||
for key in _NESTED_LIST_KEYS:
|
||||
nested = step.get(key)
|
||||
if isinstance(nested, list):
|
||||
step[key] = _traverse_and_apply(nested, edits_by_anchor, sources)
|
||||
cases = step.get("cases")
|
||||
if isinstance(cases, dict):
|
||||
for case_key, case_steps in cases.items():
|
||||
if isinstance(case_steps, list):
|
||||
cases[case_key] = _traverse_and_apply(case_steps, edits_by_anchor, sources)
|
||||
result.append(step)
|
||||
|
||||
# Insert after (highest priority closest to anchor — reversed merge order).
|
||||
for layer, edit in reversed(edits):
|
||||
if edit.operation == "insert_after":
|
||||
new_step = copy.deepcopy(edit.step)
|
||||
_record_sources_recursively(new_step, layer.source, sources)
|
||||
result.append(new_step)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def merge_steps(
|
||||
base_steps: list[dict[str, Any]],
|
||||
overlays: list[OverlayLayer],
|
||||
) -> tuple[list[dict[str, Any]], list[ComposedStep]]:
|
||||
"""Apply overlays to base steps in merge order and return composed steps.
|
||||
|
||||
*overlays* is expected to be sorted by merge order (lowest priority first,
|
||||
highest priority last). The returned step list is a deep copy of the base;
|
||||
base_steps is never mutated.
|
||||
|
||||
Higher-wins semantics are enforced for edits that target the same base
|
||||
anchor: the highest-priority edit (last in *overlays*) decides the fate of
|
||||
the anchor. A lower-priority ``remove`` cannot prevent a higher-priority
|
||||
``replace`` or ``insert_*`` on the same anchor.
|
||||
"""
|
||||
steps = copy.deepcopy(base_steps)
|
||||
sources: dict[str, str] = {}
|
||||
_init_sources_recursively(steps, sources)
|
||||
|
||||
# Group edits by anchor, preserving merge order.
|
||||
edits_by_anchor: dict[str, list[tuple[OverlayLayer, OverlayEdit]]] = {}
|
||||
for layer in overlays:
|
||||
for edit in layer.overlay.edits:
|
||||
edits_by_anchor.setdefault(edit.anchor, []).append((layer, edit))
|
||||
|
||||
# Raise early for non-remove edits that target anchors not present in the base.
|
||||
# Overlays always apply to the original tree; they cannot target steps introduced
|
||||
# by other overlays.
|
||||
base_ids = _all_base_step_ids(base_steps)
|
||||
for anchor, anchor_edits in edits_by_anchor.items():
|
||||
winning_op = anchor_edits[-1][1].operation
|
||||
if winning_op != "remove" and anchor not in base_ids:
|
||||
raise ValueError(f"Anchor '{anchor}' not found in workflow steps.")
|
||||
|
||||
# Reject edits that target anchors with a parent/descendant relationship when
|
||||
# the ancestor edit replaces or removes its subtree — those produce
|
||||
# order-dependent results. Pure insert edits on an ancestor are safe because
|
||||
# the ancestor step (and its descendants) remain intact.
|
||||
anchor_winning_ops = {
|
||||
anchor: anchor_edits[-1][1].operation
|
||||
for anchor, anchor_edits in edits_by_anchor.items()
|
||||
}
|
||||
anchor_conflicts = _check_anchor_conflicts(anchor_winning_ops, base_steps)
|
||||
if anchor_conflicts:
|
||||
raise ValueError(
|
||||
"Overlay anchor conflict(s) detected:\n - " + "\n - ".join(anchor_conflicts)
|
||||
)
|
||||
|
||||
# Apply all overlay edits via a single-pass traversal of the original tree.
|
||||
# Each edit is resolved against the original step structure, so a replacement
|
||||
# step's new ID can never be mistaken for a base anchor in a later edit group.
|
||||
result = _traverse_and_apply(steps, edits_by_anchor, sources)
|
||||
|
||||
attribution = _build_attribution(result, sources)
|
||||
return result, attribution
|
||||
|
||||
|
||||
def validate_edits(
|
||||
edits: list[OverlayEdit],
|
||||
base_step_ids: set[str],
|
||||
) -> list[str]:
|
||||
"""Validate overlay edits against a set of known base step IDs.
|
||||
|
||||
Returns a list of human-readable error messages. Does not raise.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
for idx, edit in enumerate(edits):
|
||||
if edit.operation not in VALID_OPERATIONS:
|
||||
errors.append(f"Edit {idx}: invalid operation {edit.operation!r}.")
|
||||
continue
|
||||
if edit.anchor not in base_step_ids:
|
||||
errors.append(
|
||||
f"Edit {idx}: anchor '{edit.anchor}' does not match any base step id."
|
||||
)
|
||||
if edit.operation == "remove":
|
||||
if edit.step is not None:
|
||||
errors.append(f"Edit {idx}: 'remove' must not include a step.")
|
||||
continue
|
||||
if not isinstance(edit.step, dict):
|
||||
errors.append(f"Edit {idx}: '{edit.operation}' requires a step mapping.")
|
||||
continue
|
||||
step_id = edit.step.get("id")
|
||||
if not isinstance(step_id, str) or not step_id:
|
||||
errors.append(f"Edit {idx}: step is missing required 'id'.")
|
||||
continue
|
||||
if ":" in step_id:
|
||||
errors.append(
|
||||
f"Edit {idx}: step id {step_id!r} contains ':' which is reserved "
|
||||
"for engine-generated nested IDs."
|
||||
)
|
||||
return errors
|
||||
176
src/specify_cli/workflows/overlays/schema.py
Normal file
176
src/specify_cli/workflows/overlays/schema.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Workflow overlay schema — dataclasses and validation for overlay manifests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from ...extensions import normalize_priority
|
||||
|
||||
# Safe single-segment identifiers: no path separators, no traversal, no dots.
|
||||
_SAFE_ID_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
_RESERVED_OVERLAY_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays"})
|
||||
_RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays", "runs", "steps"})
|
||||
|
||||
VALID_OPERATIONS = frozenset({"insert_after", "insert_before", "replace", "remove"})
|
||||
|
||||
# Map shorthand keys to operation names.
|
||||
_SHORTHAND_OPERATION_KEYS: frozenset[str] = VALID_OPERATIONS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OverlayEdit:
|
||||
"""A single edit operation on a workflow step list."""
|
||||
|
||||
operation: Literal["insert_after", "insert_before", "replace", "remove"]
|
||||
anchor: str
|
||||
step: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Overlay:
|
||||
"""A declared overlay (one YAML file)."""
|
||||
|
||||
id: str
|
||||
extends: str
|
||||
edits: list[OverlayEdit]
|
||||
priority: int = 10
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
def _validate_safe_id(
|
||||
value: str,
|
||||
field_name: str,
|
||||
allow_reserved: bool = False,
|
||||
reserved_ids: frozenset[str] = _RESERVED_OVERLAY_WORKFLOW_IDS,
|
||||
) -> str | None:
|
||||
"""Return an error message if *value* is not a safe path segment ID."""
|
||||
if not isinstance(value, str) or not value:
|
||||
return f"Overlay '{field_name}' is required and must be a non-empty string."
|
||||
if not _SAFE_ID_PATTERN.fullmatch(value):
|
||||
return (
|
||||
f"Overlay '{field_name}' {value!r} contains invalid characters; "
|
||||
"only lowercase letters, digits, and hyphens are allowed."
|
||||
)
|
||||
if not allow_reserved and value in reserved_ids:
|
||||
return f"Overlay '{field_name}' {value!r} is reserved."
|
||||
return None
|
||||
|
||||
|
||||
def _parse_edit(edit_raw: dict[str, Any], idx: int) -> tuple[OverlayEdit | None, str | None]:
|
||||
"""Parse a single edit dict into an OverlayEdit or an error string."""
|
||||
shorthand_keys = [key for key in _SHORTHAND_OPERATION_KEYS if key in edit_raw]
|
||||
has_operation = "operation" in edit_raw
|
||||
|
||||
operation: str | None = None
|
||||
anchor: Any = None
|
||||
|
||||
if shorthand_keys and has_operation:
|
||||
return None, (
|
||||
f"Edit at index {idx} mixes shorthand operation key "
|
||||
f"({shorthand_keys[0]!r}) with explicit 'operation' field."
|
||||
)
|
||||
|
||||
if len(shorthand_keys) > 1:
|
||||
return None, (
|
||||
f"Edit at index {idx} has multiple operation keys: "
|
||||
f"{', '.join(repr(k) for k in shorthand_keys)}."
|
||||
)
|
||||
|
||||
if shorthand_keys:
|
||||
operation = shorthand_keys[0]
|
||||
anchor = edit_raw[operation]
|
||||
elif has_operation:
|
||||
operation = edit_raw.get("operation")
|
||||
anchor = edit_raw.get("anchor")
|
||||
else:
|
||||
return None, f"Edit at index {idx} has no operation; expected one of {sorted(VALID_OPERATIONS)}."
|
||||
|
||||
if operation not in VALID_OPERATIONS:
|
||||
return None, f"Edit at index {idx} has invalid operation {operation!r}."
|
||||
|
||||
if not isinstance(anchor, str) or not anchor:
|
||||
return None, f"Edit at index {idx} has invalid 'anchor'."
|
||||
|
||||
step = edit_raw.get("step")
|
||||
if operation == "remove":
|
||||
if step is not None:
|
||||
return None, f"Edit at index {idx} ('remove') must not include 'step'."
|
||||
return OverlayEdit(operation=operation, anchor=anchor), None
|
||||
|
||||
if not isinstance(step, dict):
|
||||
return None, f"Edit at index {idx} ('{operation}') requires 'step' mapping."
|
||||
step_id = step.get("id")
|
||||
if not isinstance(step_id, str) or not step_id:
|
||||
return None, f"Edit at index {idx} step is missing required 'id'."
|
||||
if ":" in step_id:
|
||||
return None, (
|
||||
f"Edit at index {idx} step id {step_id!r} contains ':' "
|
||||
"which is reserved for engine-generated nested IDs."
|
||||
)
|
||||
return OverlayEdit(operation=operation, anchor=anchor, step=step), None
|
||||
|
||||
|
||||
def validate_overlay_yaml(data: dict[str, Any]) -> tuple[Overlay | None, list[str]]:
|
||||
"""Validate an overlay manifest dict and return (Overlay, errors).
|
||||
|
||||
Errors are returned as a list of strings; validation never raises.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return None, ["Overlay manifest must be a mapping."]
|
||||
|
||||
overlay_id = data.get("id")
|
||||
if err := _validate_safe_id(overlay_id, "id"):
|
||||
errors.append(err)
|
||||
overlay_id = ""
|
||||
|
||||
extends = data.get("extends")
|
||||
if err := _validate_safe_id(
|
||||
extends,
|
||||
"extends",
|
||||
reserved_ids=_RESERVED_WORKFLOW_IDS,
|
||||
):
|
||||
errors.append(err)
|
||||
extends = ""
|
||||
|
||||
priority = normalize_priority(data.get("priority", 10))
|
||||
|
||||
edits_raw = data.get("edits")
|
||||
edits: list[OverlayEdit] = []
|
||||
if not isinstance(edits_raw, list):
|
||||
errors.append("Overlay 'edits' is required and must be a list.")
|
||||
elif not edits_raw:
|
||||
errors.append("Overlay 'edits' must be a non-empty list.")
|
||||
else:
|
||||
for idx, edit_raw in enumerate(edits_raw):
|
||||
if not isinstance(edit_raw, dict):
|
||||
errors.append(f"Edit at index {idx} must be a mapping.")
|
||||
continue
|
||||
edit, err = _parse_edit(edit_raw, idx)
|
||||
if err:
|
||||
errors.append(err)
|
||||
continue
|
||||
if edit is not None:
|
||||
edits.append(edit)
|
||||
|
||||
enabled = data.get("enabled", True)
|
||||
if not isinstance(enabled, bool):
|
||||
errors.append("Overlay 'enabled' must be a boolean.")
|
||||
enabled = bool(enabled)
|
||||
|
||||
if errors:
|
||||
return None, errors
|
||||
|
||||
return (
|
||||
Overlay(
|
||||
id=overlay_id,
|
||||
extends=extends,
|
||||
priority=priority,
|
||||
edits=edits,
|
||||
enabled=enabled,
|
||||
),
|
||||
[],
|
||||
)
|
||||
@@ -30,6 +30,21 @@ class CommandStep(StepBase):
|
||||
|
||||
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
|
||||
command = config.get("command", "")
|
||||
# validate() rejects a non-string 'command', but the engine does not
|
||||
# auto-validate before execute(); an unvalidated run would pass the value
|
||||
# to build_command_invocation() (via _try_dispatch) and crash there with a
|
||||
# raw AttributeError (command_name.startswith(...) on a list/int/None).
|
||||
# Fail the step with the same contract error instead, mirroring the
|
||||
# 'input'/'options' guards below.
|
||||
if not isinstance(command, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Command step {config.get('id', '?')!r}: 'command' must be a "
|
||||
f"string, got {type(command).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
input_data = config.get("input", {})
|
||||
# validate() rejects a non-mapping input, but the engine does not
|
||||
# auto-validate before execute(); a workflow that skipped validation can
|
||||
@@ -179,6 +194,17 @@ class CommandStep(StepBase):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r} is missing 'command' field."
|
||||
)
|
||||
elif not isinstance(config["command"], str):
|
||||
# execute() passes 'command' straight to the integration's
|
||||
# build_command_invocation(), which does command_name.startswith(...);
|
||||
# a non-string (null, list, int) crashes there with a raw
|
||||
# AttributeError once dispatch is attempted. Reject it at validation,
|
||||
# mirroring the prompt-step 'prompt' and shell-step 'run' type checks.
|
||||
# An expression like "{{ ... }}" is still a str, so it stays valid.
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'command' must be a "
|
||||
f"string, got {type(config['command']).__name__}."
|
||||
)
|
||||
# execute() iterates input.items() and options.update(step_options); a
|
||||
# non-mapping here would raise at run time. Validate the shape like the
|
||||
# sibling steps (switch 'cases', fan-out 'step') so it is reported, not
|
||||
|
||||
@@ -42,6 +42,28 @@ class FanInStep(StepBase):
|
||||
output={"results": []},
|
||||
)
|
||||
|
||||
# A non-string entry can never match a real step id. An unhashable one
|
||||
# (a list/dict from a YAML indentation slip like ``wait_for: [[a, b]]``)
|
||||
# crashes the whole run at ``context.steps.get(step_id, ...)`` below with
|
||||
# a raw TypeError; a hashable-but-non-string one (``wait_for: [123]``)
|
||||
# silently joins an empty ``{}`` and still reports COMPLETED — the exact
|
||||
# "silent empty result + COMPLETED" wiring bug the whole-list guard above
|
||||
# and the engine's fan-in validation (engine.py) both reject. The engine
|
||||
# does not auto-validate step config, so fail this step loudly on an
|
||||
# unvalidated run too, using the engine's phrasing.
|
||||
bad_entries = [w for w in wait_for if not isinstance(w, str)]
|
||||
if bad_entries:
|
||||
first = bad_entries[0]
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Fan-in step {config.get('id', '?')!r}: 'wait_for' entries "
|
||||
f"must be step-id strings, got {type(first).__name__} "
|
||||
f"({first!r})."
|
||||
),
|
||||
output={"results": []},
|
||||
)
|
||||
|
||||
# Collect results from referenced steps
|
||||
results = []
|
||||
for step_id in wait_for:
|
||||
|
||||
@@ -25,6 +25,33 @@ class FanOutStep(StepBase):
|
||||
max_concurrency = config.get("max_concurrency", 1)
|
||||
step_template = config.get("step", {})
|
||||
|
||||
# The engine does not auto-validate step config (see
|
||||
# ``WorkflowEngine.load_workflow``). On a COMPLETED fan-out it reads the
|
||||
# ``step_template`` back out and, when it is truthy, calls
|
||||
# ``template.get("id", ...)`` in ``_run_fan_out``. A truthy non-mapping
|
||||
# ``step`` (a scalar or list authoring mistake) would crash the whole
|
||||
# run with AttributeError there — the engine invokes ``execute`` and
|
||||
# ``_run_fan_out`` with no surrounding try/except. ``validate`` already
|
||||
# rejects a non-mapping ``step``; fail this step loudly on an
|
||||
# unvalidated run instead, mirroring the ``items`` guard below. An empty
|
||||
# or absent ``step`` defaults to ``{}`` (falsy) and the engine's
|
||||
# ``if template and items`` skips fan-out, so it stays valid here.
|
||||
if not isinstance(step_template, dict):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Fan-out step {config.get('id', '?')!r}: 'step' must be a "
|
||||
f"mapping (nested step template), got "
|
||||
f"{type(step_template).__name__}."
|
||||
),
|
||||
output={
|
||||
"items": [],
|
||||
"max_concurrency": max_concurrency,
|
||||
"step_template": {},
|
||||
"item_count": 0,
|
||||
},
|
||||
)
|
||||
|
||||
if not isinstance(items, list):
|
||||
# A non-list here is a wiring error (the expression did not
|
||||
# resolve to a collection); silently fanning out over zero
|
||||
@@ -66,8 +93,13 @@ class FanOutStep(StepBase):
|
||||
f"Fan-out step {config.get('id', '?')!r} is missing "
|
||||
f"'step' field (nested step template)."
|
||||
)
|
||||
step = config.get("step")
|
||||
if step is not None and not isinstance(step, dict):
|
||||
elif not isinstance(config["step"], dict):
|
||||
# A present-but-non-mapping ``step`` (including an explicit
|
||||
# ``step: null``) is an authoring mistake. ``config.get("step", {})``
|
||||
# in ``execute`` only substitutes the ``{}`` default for an *absent*
|
||||
# key, so an explicit ``None`` reaches the runtime guard and FAILS
|
||||
# the step. Reject it here too so a workflow cannot pass validation
|
||||
# and then fail during execution.
|
||||
errors.append(
|
||||
f"Fan-out step {config.get('id', '?')!r}: 'step' must be a mapping."
|
||||
)
|
||||
|
||||
@@ -43,6 +43,35 @@ class GateStep(StepBase):
|
||||
options = config.get("options", ["approve", "reject"])
|
||||
on_reject = config.get("on_reject", "abort")
|
||||
|
||||
# ``validate`` rejects a non-list (or empty) ``options``, and requires
|
||||
# every option to be a string, but the engine does not auto-validate
|
||||
# before ``execute``. An unvalidated run with a scalar/dict/None
|
||||
# ``options`` would otherwise reach ``_prompt`` and crash the whole run
|
||||
# with a raw ``TypeError`` (``enumerate``/``len`` on a non-iterable) or
|
||||
# ``KeyError`` (indexing a dict); a non-string option would crash at the
|
||||
# ``choice.lower()`` reject check with ``AttributeError``. Fail this step
|
||||
# loudly instead — mirroring the switch 'cases' and command 'input'
|
||||
# guards. Checked before the non-TTY short-circuit so the error surfaces
|
||||
# in CI too, rather than PAUSING and crashing later on interactive resume.
|
||||
if (
|
||||
not isinstance(options, list)
|
||||
or not options
|
||||
or not all(isinstance(o, str) for o in options)
|
||||
):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Gate step {config.get('id', '?')!r}: 'options' must be a "
|
||||
f"non-empty list of strings, got {type(options).__name__}."
|
||||
),
|
||||
output={
|
||||
"message": message,
|
||||
"options": options,
|
||||
"on_reject": on_reject,
|
||||
"choice": None,
|
||||
},
|
||||
)
|
||||
|
||||
show_file = config.get("show_file")
|
||||
if isinstance(show_file, str) and "{{" in show_file:
|
||||
show_file = evaluate_expression(show_file, context)
|
||||
|
||||
@@ -160,4 +160,16 @@ class PromptStep(StepBase):
|
||||
errors.append(
|
||||
f"Prompt step {config.get('id', '?')!r} is missing 'prompt' field."
|
||||
)
|
||||
elif not isinstance(config["prompt"], str):
|
||||
# execute() str()-coerces prompt and dispatches it to the
|
||||
# integration CLI, so a null or list 'prompt' would send the Python
|
||||
# repr ('None', "['review', 'this']") to the model as instructions —
|
||||
# silently wrong, with no error. Reject non-strings at validation,
|
||||
# mirroring the shell-step 'run' and command-step input/options type
|
||||
# checks. An expression like "{{ ... }}" is still a str, so it stays
|
||||
# valid.
|
||||
errors.append(
|
||||
f"Prompt step {config.get('id', '?')!r}: 'prompt' must be a "
|
||||
f"string, got {type(config['prompt']).__name__}."
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -11,6 +11,7 @@ handoffs:
|
||||
scripts:
|
||||
sh: scripts/bash/setup-plan.sh --json
|
||||
ps: scripts/powershell/setup-plan.ps1 -Json
|
||||
py: scripts/python/setup_plan.py --json
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
@@ -12,6 +12,7 @@ handoffs:
|
||||
scripts:
|
||||
sh: scripts/bash/setup-tasks.sh --json
|
||||
ps: scripts/powershell/setup-tasks.ps1 -Json
|
||||
py: scripts/python/setup_tasks.py --json
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
@@ -5,6 +5,8 @@ built-in, install policy gating, payload parsing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
@@ -37,6 +39,7 @@ def test_builtin_default_stack_when_no_config(tmp_path: Path):
|
||||
assert ids == ["default", "community"]
|
||||
assert sources[0].install_policy is InstallPolicy.INSTALL_ALLOWED
|
||||
assert sources[1].install_policy is InstallPolicy.DISCOVERY_ONLY
|
||||
assert sources[1].priority == 20
|
||||
assert all(s.scope is Scope.BUILTIN for s in sources)
|
||||
|
||||
|
||||
@@ -95,6 +98,29 @@ def test_builtin_default_stack_constant_shape():
|
||||
assert ids == {"default", "community"}
|
||||
|
||||
|
||||
def test_repository_community_bundle_catalog_matches_contract():
|
||||
catalog_path = Path(__file__).parents[2] / "bundles" / "catalog.community.json"
|
||||
payload = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert payload["schema_version"] == "1.0"
|
||||
assert payload["catalog_url"].endswith("/bundles/catalog.community.json")
|
||||
entries = load_catalog_payload(payload)
|
||||
assert all(entry.verified is False for entry in entries.values())
|
||||
|
||||
|
||||
def test_wheel_packages_community_bundle_catalog():
|
||||
repo_root = Path(__file__).parents[2]
|
||||
with (repo_root / "pyproject.toml").open("rb") as pyproject_file:
|
||||
pyproject = tomllib.load(pyproject_file)
|
||||
|
||||
force_include = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][
|
||||
"force-include"
|
||||
]
|
||||
assert force_include["bundles/catalog.community.json"] == (
|
||||
"specify_cli/core_pack/bundles/catalog.community.json"
|
||||
)
|
||||
|
||||
|
||||
def test_catalog_entry_rejects_string_tags():
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
from tests.extensions.test_extension_agent_context import (
|
||||
BASH,
|
||||
POWERSHELL,
|
||||
_bash_posix_path,
|
||||
_run_bash_agent_context_script,
|
||||
|
||||
@@ -38,6 +38,27 @@ def test_resolve_prefers_highest_precedence_source():
|
||||
assert resolved.install_allowed is False
|
||||
|
||||
|
||||
def test_explicit_catalog_shadows_builtin_community_at_default_priority():
|
||||
sources = [
|
||||
_source("community", 20, "discovery-only"),
|
||||
_source("explicit", 10, "install-allowed"),
|
||||
]
|
||||
payloads = {
|
||||
"community": catalog_payload({
|
||||
"shared": catalog_entry_dict("shared", version="1.0.0"),
|
||||
}),
|
||||
"explicit": catalog_payload({
|
||||
"shared": catalog_entry_dict("shared", version="2.0.0"),
|
||||
}),
|
||||
}
|
||||
|
||||
resolved = _stack(sources, payloads).resolve("shared")
|
||||
|
||||
assert resolved.source.id == "explicit"
|
||||
assert resolved.entry.version == "2.0.0"
|
||||
assert resolved.install_allowed is True
|
||||
|
||||
|
||||
def test_resolve_unknown_bundle_errors():
|
||||
stack = _stack(
|
||||
[_source("only", 1, "install-allowed")],
|
||||
|
||||
@@ -31,6 +31,25 @@ def test_builtin_catalog_resolves_offline():
|
||||
assert stack.search() == []
|
||||
|
||||
|
||||
def test_builtin_community_catalog_resolves_from_packaged_snapshot_offline():
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
source = _src(
|
||||
"community",
|
||||
"builtin://community",
|
||||
priority=20,
|
||||
policy="discovery-only",
|
||||
)
|
||||
payload = fetcher(source)
|
||||
stack = CatalogStack([source], fetcher)
|
||||
|
||||
assert isinstance(payload.get("bundles"), dict)
|
||||
assert all(
|
||||
result.source.id == "community" and not result.install_allowed
|
||||
for result in stack.search()
|
||||
)
|
||||
assert stack.sources[0].install_allowed is False
|
||||
|
||||
|
||||
def test_file_catalog_resolves_offline(tmp_path: Path):
|
||||
catalog_path = tmp_path / "catalog.json"
|
||||
write_catalog_file(catalog_path, {"demo": catalog_entry_dict("demo")})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives."""
|
||||
|
||||
import shlex
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -216,6 +218,24 @@ class TestBuildCommandInvocation:
|
||||
i = get_integration("codex")
|
||||
assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo"
|
||||
|
||||
def test_forge_core_command_hyphenated(self):
|
||||
"""Forge installs hyphenated slash-commands (/speckit-<name>), so the
|
||||
dispatch invocation must be hyphenated too — not the dotted default it
|
||||
would inherit from MarkdownIntegration."""
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("forge")
|
||||
assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
|
||||
assert i.build_command_invocation("plan") == "/speckit-plan"
|
||||
|
||||
def test_forge_extension_command_hyphenated(self):
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("forge")
|
||||
assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
|
||||
assert (
|
||||
i.build_command_invocation("speckit.git.commit", "fix typo")
|
||||
== "/speckit-git-commit fix typo"
|
||||
)
|
||||
|
||||
|
||||
class TestResolveCommandRefs:
|
||||
"""Tests for __SPECKIT_COMMAND_<NAME>__ placeholder resolution."""
|
||||
@@ -477,19 +497,41 @@ class TestProcessTemplatePyScriptType:
|
||||
assert ".specify/scripts/bash/check-prerequisites.sh --json" in result
|
||||
assert "python" not in result
|
||||
|
||||
def test_body_scripts_example_does_not_override_frontmatter(self):
|
||||
content = (
|
||||
"---\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/real.sh --json\n"
|
||||
"---\n"
|
||||
"Run {SCRIPT} now.\n"
|
||||
"```yaml\n"
|
||||
"scripts:\n"
|
||||
" sh: examples/not-the-command.sh\n"
|
||||
"```\n"
|
||||
)
|
||||
|
||||
result = IntegrationBase.process_template(content, "agent", "sh")
|
||||
|
||||
assert ".specify/scripts/bash/real.sh --json" in result
|
||||
assert "examples/not-the-command.sh" in result
|
||||
|
||||
def test_py_quotes_interpreter_with_spaces(self, monkeypatch):
|
||||
# An interpreter path containing whitespace (e.g. Windows
|
||||
# ``Program Files``) must be quoted so it isn't split into args.
|
||||
interpreter = r"C:\Program Files\Python\python.exe"
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable",
|
||||
r"C:\Program Files\Python\python.exe",
|
||||
interpreter,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.os", SimpleNamespace(name="posix")
|
||||
)
|
||||
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
|
||||
assert (
|
||||
'"C:\\Program Files\\Python\\python.exe" '
|
||||
f"{shlex.quote(interpreter)} "
|
||||
".specify/scripts/python/check-prerequisites.py --json"
|
||||
) in result
|
||||
|
||||
@@ -511,6 +553,39 @@ class TestProcessTemplatePyScriptType:
|
||||
)
|
||||
assert ".venv/bin/python .specify/scripts/python/check-prerequisites.py" in result
|
||||
|
||||
def test_setup_py_falls_back_to_platform_shell(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
template = tmp_path / "fallback.md"
|
||||
template.write_text(
|
||||
"---\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/check-prerequisites.sh --json\n"
|
||||
" ps: scripts/powershell/check-prerequisites.ps1 -Json\n"
|
||||
"---\n"
|
||||
"Run {SCRIPT} now.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
integration = StubIntegration()
|
||||
monkeypatch.setattr(
|
||||
integration, "list_command_templates", lambda: [template]
|
||||
)
|
||||
|
||||
created = integration.setup(
|
||||
tmp_path,
|
||||
IntegrationManifest("stub", tmp_path),
|
||||
script_type="py",
|
||||
)
|
||||
|
||||
rendered = created[0].read_text(encoding="utf-8")
|
||||
expected = (
|
||||
".specify/scripts/powershell/check-prerequisites.ps1"
|
||||
if sys.platform == "win32"
|
||||
else ".specify/scripts/bash/check-prerequisites.sh"
|
||||
)
|
||||
assert "{SCRIPT}" not in rendered
|
||||
assert expected in rendered
|
||||
|
||||
|
||||
class TestInstallScriptsPython:
|
||||
def _make_integration_with_scripts(self, monkeypatch, tmp_path):
|
||||
|
||||
@@ -1,10 +1,927 @@
|
||||
"""Tests for BobIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
import os
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
||||
from specify_cli.integrations.base import SkillsIntegration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
class TestBobIntegration(MarkdownIntegrationTests):
|
||||
KEY = "bob"
|
||||
FOLDER = ".bob/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".bob/commands"
|
||||
class TestBobIntegrationRegistration:
|
||||
def test_registered(self):
|
||||
assert "bob" in INTEGRATION_REGISTRY
|
||||
assert get_integration("bob") is not None
|
||||
|
||||
def test_is_integration_base_not_skills_integration(self):
|
||||
"""BobIntegration extends IntegrationBase directly — not SkillsIntegration.
|
||||
|
||||
Bob is dual-mode (skills by default, legacy commands via
|
||||
``--legacy-commands``), so its skills-ness is a per-project config
|
||||
decision resolved by the ``is_skills_mode`` hook — not a class-hierarchy
|
||||
property. It therefore must NOT be a ``SkillsIntegration`` (which is
|
||||
reserved for statically skills-only agents); shared code consults
|
||||
``is_skills_mode(parsed_options)`` instead of ``isinstance``.
|
||||
``invoke_separator='-'`` is set explicitly on the class to match the
|
||||
default (skills) layout.
|
||||
"""
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
bob = get_integration("bob")
|
||||
assert isinstance(bob, IntegrationBase)
|
||||
assert not isinstance(bob, SkillsIntegration)
|
||||
assert bob.invoke_separator == "-"
|
||||
|
||||
def test_key_and_config(self):
|
||||
bob = get_integration("bob")
|
||||
assert bob.key == "bob"
|
||||
assert bob.config["folder"] == ".bob/"
|
||||
# registrar_config mirrors the legacy commands layout so that
|
||||
# CommandRegistrar.AGENT_CONFIGS["bob"] follows the Copilot pattern:
|
||||
# extension registration writes to .bob/commands/ for legacy-mode
|
||||
# projects and is skipped for skills-mode projects (skills_mode_active).
|
||||
assert bob.config["commands_subdir"] == "commands"
|
||||
assert bob.registrar_config["dir"] == ".bob/commands"
|
||||
assert bob.registrar_config["extension"] == ".md"
|
||||
|
||||
def test_invoke_separator_is_hyphen(self):
|
||||
"""Class-level invoke_separator must be '-' so CommandRegistrar.AGENT_CONFIGS
|
||||
generates correct /speckit-<name> refs without calling effective_invoke_separator."""
|
||||
bob = get_integration("bob")
|
||||
assert bob.invoke_separator == "-"
|
||||
|
||||
|
||||
class TestBobOptionsFlag:
|
||||
def test_options_include_legacy_commands_flag(self):
|
||||
bob = get_integration("bob")
|
||||
opts = bob.options()
|
||||
legacy_opts = [o for o in opts if o.name == "--legacy-commands"]
|
||||
assert len(legacy_opts) == 1
|
||||
opt = legacy_opts[0]
|
||||
assert opt.is_flag is True
|
||||
# Legacy must be OPT-IN (default=False) — skills are the default
|
||||
assert opt.default is False
|
||||
|
||||
def test_options_include_skills_migration_flag(self):
|
||||
"""Review #3415, 4724160183, comment 1: a ``--skills`` opt-in exists as
|
||||
the supported migration path from legacy commands to the skills layout.
|
||||
It is distinct from the pre-skills-default ``--skills`` flag: here it
|
||||
*forces* skills mode over on-disk auto-detection.
|
||||
"""
|
||||
bob = get_integration("bob")
|
||||
opts = bob.options()
|
||||
skills_opts = [o for o in opts if o.name == "--skills"]
|
||||
assert len(skills_opts) == 1
|
||||
opt = skills_opts[0]
|
||||
assert opt.is_flag is True
|
||||
# Opt-in: disk auto-detection remains the default behavior.
|
||||
assert opt.default is False
|
||||
|
||||
|
||||
class TestBobIsSkillsModeHook:
|
||||
"""The is_skills_mode hook is the single source of truth for the mode."""
|
||||
|
||||
def test_default_is_skills(self):
|
||||
bob = get_integration("bob")
|
||||
assert bob.is_skills_mode(None) is True
|
||||
assert bob.is_skills_mode({}) is True
|
||||
|
||||
def test_legacy_commands_disables_skills(self):
|
||||
bob = get_integration("bob")
|
||||
assert bob.is_skills_mode({"legacy_commands": True}) is False
|
||||
|
||||
def test_existing_commands_layout_preserved_on_use(self, tmp_path):
|
||||
"""Regression (review #3415): an existing Bob 1.x project (managed
|
||||
``.bob/commands/speckit.*.md`` on disk, no stored ``legacy_commands``)
|
||||
must NOT be treated as skills mode when re-resolved with a
|
||||
project_root, so ``use``/``switch``/``upgrade`` never silently migrate
|
||||
it to skills.
|
||||
"""
|
||||
bob = get_integration("bob")
|
||||
cmds = tmp_path / ".bob" / "commands"
|
||||
cmds.mkdir(parents=True)
|
||||
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
|
||||
# No parsed options at all — the pre-existing-install scenario.
|
||||
assert bob.is_skills_mode(None, project_root=tmp_path) is False
|
||||
assert bob.is_skills_mode({}, project_root=tmp_path) is False
|
||||
|
||||
def test_existing_skills_layout_stays_skills_on_use(self, tmp_path):
|
||||
"""A project with managed ``speckit-*`` skills resolves to skills mode."""
|
||||
bob = get_integration("bob")
|
||||
(tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
|
||||
assert bob.is_skills_mode(None, project_root=tmp_path) is True
|
||||
|
||||
def test_managed_commands_with_unrelated_skills_dir_stays_legacy(
|
||||
self, tmp_path
|
||||
):
|
||||
"""Regression (review #3415, 4723246468): a legacy Spec Kit install
|
||||
(managed ``.bob/commands/speckit.*.md``) that *also* carries unrelated
|
||||
Bob 2 skills (a ``.bob/skills/`` dir with no managed ``speckit-*``
|
||||
skills) must stay in command mode — the mere presence of a skills
|
||||
directory is not evidence that Spec Kit is skills-based.
|
||||
"""
|
||||
bob = get_integration("bob")
|
||||
cmds = tmp_path / ".bob" / "commands"
|
||||
cmds.mkdir(parents=True)
|
||||
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
|
||||
# An unrelated (non-Spec-Kit) skill the user authored.
|
||||
(tmp_path / ".bob" / "skills" / "my-own-skill").mkdir(parents=True)
|
||||
assert bob.is_skills_mode(None, project_root=tmp_path) is False
|
||||
assert bob.effective_invoke_separator(None, project_root=tmp_path) == "."
|
||||
|
||||
def test_managed_skills_win_when_both_layouts_present(self, tmp_path):
|
||||
"""When managed Spec Kit skills exist, skills mode wins even if a stale
|
||||
managed command file is still on disk (upgrade leftover)."""
|
||||
bob = get_integration("bob")
|
||||
cmds = tmp_path / ".bob" / "commands"
|
||||
cmds.mkdir(parents=True)
|
||||
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
|
||||
(tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
|
||||
assert bob.is_skills_mode(None, project_root=tmp_path) is True
|
||||
|
||||
def test_fresh_project_defaults_to_skills_with_project_root(self, tmp_path):
|
||||
"""A project with no managed ``.bob/`` artifacts yet defaults to skills."""
|
||||
bob = get_integration("bob")
|
||||
assert bob.is_skills_mode(None, project_root=tmp_path) is True
|
||||
|
||||
def test_explicit_legacy_flag_wins_over_disk_layout(self, tmp_path):
|
||||
"""An explicit ``--legacy-commands`` overrides on-disk detection."""
|
||||
bob = get_integration("bob")
|
||||
(tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
|
||||
assert (
|
||||
bob.is_skills_mode({"legacy_commands": True}, project_root=tmp_path)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_explicit_skills_flag_forces_skills_over_legacy_disk_layout(
|
||||
self, tmp_path
|
||||
):
|
||||
"""Regression (review #3415, 4724160183, comment 1).
|
||||
|
||||
``--skills`` is the supported migration / opt-in: it must force skills
|
||||
mode even when a managed legacy ``.bob/commands`` layout is on disk
|
||||
(which otherwise auto-detects to legacy). This gives
|
||||
``integration upgrade bob --integration-options="--skills"`` a path out
|
||||
of legacy mode instead of being trapped by disk detection.
|
||||
"""
|
||||
bob = get_integration("bob")
|
||||
cmds = tmp_path / ".bob" / "commands"
|
||||
cmds.mkdir(parents=True)
|
||||
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
|
||||
assert bob.is_skills_mode({"skills": True}, project_root=tmp_path) is True
|
||||
assert (
|
||||
bob.effective_invoke_separator({"skills": True}, project_root=tmp_path)
|
||||
== "-"
|
||||
)
|
||||
|
||||
def test_skills_and_legacy_flags_are_mutually_exclusive(self):
|
||||
"""Passing both ``--skills`` and ``--legacy-commands`` exits cleanly."""
|
||||
import typer
|
||||
|
||||
bob = get_integration("bob")
|
||||
with pytest.raises(typer.Exit):
|
||||
bob.is_skills_mode({"skills": True, "legacy_commands": True})
|
||||
|
||||
def test_effective_invoke_separator_tracks_mode(self):
|
||||
bob = get_integration("bob")
|
||||
assert bob.effective_invoke_separator(None) == "-"
|
||||
assert bob.effective_invoke_separator({"legacy_commands": True}) == "."
|
||||
assert bob.effective_invoke_separator({"skills": True}) == "-"
|
||||
|
||||
def test_invoke_separator_for_mode_tracks_persisted_state(self):
|
||||
"""Registration paths resolve the separator from persisted ai_skills."""
|
||||
bob = get_integration("bob")
|
||||
assert bob.invoke_separator_for_mode(True) == "-"
|
||||
assert bob.invoke_separator_for_mode(False) == "."
|
||||
|
||||
def test_no_skills_mode_method_leaks(self):
|
||||
"""The old callable _skills_mode method must be gone; consumers use the hook."""
|
||||
bob = get_integration("bob")
|
||||
assert not callable(getattr(bob, "_skills_mode", None))
|
||||
|
||||
|
||||
class TestBobDefaultSkillsMode:
|
||||
"""Default mode: .bob/skills/speckit-<name>/SKILL.md layout."""
|
||||
|
||||
def test_setup_creates_skill_files(self, tmp_path):
|
||||
bob = get_integration("bob")
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
created = bob.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
for f in created:
|
||||
assert f.exists()
|
||||
assert f.name == "SKILL.md"
|
||||
assert f.parent.name.startswith("speckit-")
|
||||
|
||||
def test_setup_writes_to_correct_directory(self, tmp_path):
|
||||
bob = get_integration("bob")
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
bob.setup(tmp_path, m)
|
||||
skills_dir = tmp_path / ".bob" / "skills"
|
||||
assert skills_dir.is_dir()
|
||||
|
||||
def test_setup_does_not_warn(self, tmp_path):
|
||||
bob = get_integration("bob")
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
bob.setup(tmp_path, m)
|
||||
assert not any(
|
||||
"legacy" in str(item.message).lower() for item in caught
|
||||
)
|
||||
|
||||
def test_setup_no_commands_dir(self, tmp_path):
|
||||
bob = get_integration("bob")
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
bob.setup(tmp_path, m)
|
||||
assert not (tmp_path / ".bob" / "commands").exists()
|
||||
|
||||
def test_skill_directory_structure(self, tmp_path):
|
||||
"""Each command produces speckit-<name>/SKILL.md."""
|
||||
bob = get_integration("bob")
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
created = bob.setup(tmp_path, m)
|
||||
|
||||
expected_commands = {
|
||||
"analyze", "clarify", "constitution", "converge", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
}
|
||||
actual_commands = {f.parent.name.removeprefix("speckit-") for f in created}
|
||||
assert actual_commands == expected_commands
|
||||
|
||||
def test_skill_frontmatter_structure(self, tmp_path):
|
||||
"""SKILL.md must have name, description, compatibility, metadata."""
|
||||
bob = get_integration("bob")
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
created = bob.setup(tmp_path, m)
|
||||
for f in created:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert content.startswith("---\n"), f"{f} missing frontmatter"
|
||||
parts = content.split("---", 2)
|
||||
fm = yaml.safe_load(parts[1])
|
||||
assert "name" in fm
|
||||
assert "description" in fm
|
||||
assert "compatibility" in fm
|
||||
assert "metadata" in fm
|
||||
assert fm["metadata"]["author"] == "github-spec-kit"
|
||||
|
||||
def test_templates_are_processed(self, tmp_path):
|
||||
bob = get_integration("bob")
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
created = bob.setup(tmp_path, m)
|
||||
for f in created:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
|
||||
def test_command_refs_use_hyphen_separator(self, tmp_path):
|
||||
"""Default skills layout must use /speckit-<name>, not /speckit.<name>."""
|
||||
bob = get_integration("bob")
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
created = bob.setup(tmp_path, m)
|
||||
for f in created:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "/speckit." not in content, (
|
||||
f"{f.name} contains dot-notation /speckit. reference; "
|
||||
"skills must use /speckit-<name>"
|
||||
)
|
||||
|
||||
def test_all_files_tracked_in_manifest(self, tmp_path):
|
||||
bob = get_integration("bob")
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
created = bob.setup(tmp_path, m)
|
||||
for f in created:
|
||||
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
||||
assert rel in m.files, f"{rel} not tracked in manifest"
|
||||
|
||||
def test_install_uninstall_roundtrip(self, tmp_path):
|
||||
bob = get_integration("bob")
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
created = bob.install(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
m.save()
|
||||
for f in created:
|
||||
assert f.exists()
|
||||
removed, skipped = bob.uninstall(tmp_path, m)
|
||||
assert len(removed) == len(created)
|
||||
assert skipped == []
|
||||
|
||||
|
||||
class TestBobLegacyCommandsMode:
|
||||
"""Legacy opt-in mode: .bob/commands/speckit.<name>.md layout."""
|
||||
|
||||
def test_setup_legacy_creates_markdown_files(self, tmp_path):
|
||||
from specify_cli.integrations.bob import BobIntegration
|
||||
bob = BobIntegration()
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
created = bob.setup(tmp_path, m, parsed_options={"legacy_commands": True})
|
||||
assert len(created) > 0
|
||||
for f in created:
|
||||
assert f.exists()
|
||||
assert f.suffix == ".md"
|
||||
assert f.name.startswith("speckit.")
|
||||
assert f.parent == tmp_path / ".bob" / "commands"
|
||||
|
||||
def test_setup_legacy_warns_deprecated(self, tmp_path):
|
||||
from specify_cli.integrations.bob import BobIntegration
|
||||
bob = BobIntegration()
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
with pytest.warns(UserWarning, match="Bob legacy commands mode"):
|
||||
bob.setup(tmp_path, m, parsed_options={"legacy_commands": True})
|
||||
|
||||
def test_setup_legacy_no_skills_dir(self, tmp_path):
|
||||
from specify_cli.integrations.bob import BobIntegration
|
||||
bob = BobIntegration()
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
bob.setup(tmp_path, m, parsed_options={"legacy_commands": True})
|
||||
assert not (tmp_path / ".bob" / "skills").exists()
|
||||
|
||||
def test_setup_legacy_templates_are_processed(self, tmp_path):
|
||||
from specify_cli.integrations.bob import BobIntegration
|
||||
bob = BobIntegration()
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
bob.setup(tmp_path, m, parsed_options={"legacy_commands": True})
|
||||
commands_dir = tmp_path / ".bob" / "commands"
|
||||
for md_file in commands_dir.glob("speckit.*.md"):
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "__AGENT__" not in content
|
||||
assert "{ARGS}" not in content
|
||||
assert "__SPECKIT_COMMAND_" not in content
|
||||
|
||||
def test_setup_legacy_all_files_tracked(self, tmp_path):
|
||||
from specify_cli.integrations.bob import BobIntegration
|
||||
bob = BobIntegration()
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
created = bob.setup(tmp_path, m, parsed_options={"legacy_commands": True})
|
||||
for f in created:
|
||||
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
||||
assert rel in m.files, f"{rel} not tracked in manifest"
|
||||
|
||||
def test_setup_legacy_uninstall_roundtrip(self, tmp_path):
|
||||
from specify_cli.integrations.bob import BobIntegration
|
||||
bob = BobIntegration()
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
created = bob.install(tmp_path, m, parsed_options={"legacy_commands": True})
|
||||
assert len(created) > 0
|
||||
m.save()
|
||||
removed, skipped = bob.uninstall(tmp_path, m)
|
||||
assert len(removed) == len(created)
|
||||
assert skipped == []
|
||||
|
||||
|
||||
class TestBobInitFlowDefault:
|
||||
"""CLI init creates skills by default."""
|
||||
|
||||
def test_init_default_creates_skills(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
target = tmp_path / "test-proj"
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", str(target), "--integration", "bob",
|
||||
"--ignore-agent-tools", "--script", "sh",
|
||||
])
|
||||
assert result.exit_code == 0, f"init --integration bob failed: {result.output}"
|
||||
assert (target / ".bob" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert not (target / ".bob" / "commands").exists()
|
||||
|
||||
def test_init_default_complete_file_inventory_sh(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "inventory-sh-bob"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "bob", "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
commands = [
|
||||
"analyze", "clarify", "constitution", "converge", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
for cmd in commands:
|
||||
assert (project / ".bob" / "skills" / f"speckit-{cmd}" / "SKILL.md").exists(), (
|
||||
f"Missing .bob/skills/speckit-{cmd}/SKILL.md"
|
||||
)
|
||||
|
||||
|
||||
class TestBobInitFlowLegacy:
|
||||
"""CLI init with --legacy-commands produces .bob/commands/*.md."""
|
||||
|
||||
def test_init_legacy_creates_commands(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
target = tmp_path / "test-proj"
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", str(target), "--integration", "bob",
|
||||
"--integration-options", "--legacy-commands",
|
||||
"--ignore-agent-tools", "--script", "sh",
|
||||
])
|
||||
assert result.exit_code == 0, f"init --integration bob --legacy-commands failed: {result.output}"
|
||||
assert (target / ".bob" / "commands" / "speckit.plan.md").exists()
|
||||
assert not (target / ".bob" / "skills").exists()
|
||||
|
||||
def test_init_legacy_does_not_set_ai_skills(self, tmp_path):
|
||||
"""Legacy install must NOT write ai_skills=True to init-options.json.
|
||||
|
||||
Behavioral guard for the dual-mode contract: with --legacy-commands,
|
||||
BobIntegration.is_skills_mode(parsed_options) returns False, so
|
||||
_update_init_options_for_integration must not persist ai_skills=True.
|
||||
(Regression origin: shared code previously probed a bound _skills_mode
|
||||
method object, which is always truthy, and wrongly enabled skills for
|
||||
legacy projects.)
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli import load_init_options
|
||||
|
||||
target = tmp_path / "test-proj"
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", str(target), "--integration", "bob",
|
||||
"--integration-options", "--legacy-commands",
|
||||
"--ignore-agent-tools", "--script", "sh",
|
||||
])
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
init_opts = load_init_options(target)
|
||||
assert init_opts.get("ai_skills") is not True, (
|
||||
"Legacy Bob project must not have ai_skills=True in init-options.json"
|
||||
)
|
||||
|
||||
|
||||
class TestBobRegistrarConfig:
|
||||
"""Verify AGENT_CONFIGS["bob"] follows the Copilot pattern for extension registration."""
|
||||
|
||||
def test_registrar_config_uses_commands_layout(self):
|
||||
"""AGENT_CONFIGS["bob"] must use the legacy .md layout (not /SKILL.md).
|
||||
|
||||
This mirrors Copilot: the static registrar config targets the non-skills
|
||||
format so that:
|
||||
- skills_mode_active becomes True when ai_skills=True, preventing
|
||||
extension registration from writing SKILL.md files into .bob/skills/
|
||||
on projects that never asked for legacy files.
|
||||
- legacy-mode projects receive extension .md files in .bob/commands/.
|
||||
"""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
registrar = CommandRegistrar()
|
||||
bob_cfg = registrar.AGENT_CONFIGS.get("bob")
|
||||
assert bob_cfg is not None, "bob must be in AGENT_CONFIGS"
|
||||
assert bob_cfg["extension"] == ".md", (
|
||||
"AGENT_CONFIGS['bob']['extension'] must be '.md' so that "
|
||||
"skills_mode_active=True suppresses extension registration on "
|
||||
"skills-mode projects (mirrors the Copilot pattern)"
|
||||
)
|
||||
assert bob_cfg["dir"] == ".bob/commands"
|
||||
|
||||
def test_skills_mode_project_extension_registration_skipped(self, tmp_path):
|
||||
"""Extension registrar skips Bob on skills-mode projects (no .bob/commands dir)."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
# Simulate a skills-mode Bob project: .bob/skills exists, .bob/commands does not
|
||||
(tmp_path / ".bob" / "skills").mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
results = registrar.register_commands_for_all_agents(
|
||||
commands=[{"name": "speckit.test-cmd", "file": "test.md"}],
|
||||
source_id="test",
|
||||
source_dir=tmp_path,
|
||||
project_root=tmp_path,
|
||||
)
|
||||
# Bob must not appear in results — .bob/commands doesn't exist
|
||||
assert "bob" not in results
|
||||
|
||||
def test_legacy_mode_project_extension_registration_runs(self, tmp_path):
|
||||
"""Extension registrar writes to .bob/commands/ for legacy-mode projects."""
|
||||
import textwrap
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
# Simulate a legacy-mode Bob project: .bob/commands exists, .bob/skills does not
|
||||
commands_dir = tmp_path / ".bob" / "commands"
|
||||
commands_dir.mkdir(parents=True)
|
||||
|
||||
# Provide a minimal command source file
|
||||
cmd_file = tmp_path / "test.md"
|
||||
cmd_file.write_text(
|
||||
textwrap.dedent("""\
|
||||
---
|
||||
description: "Test command"
|
||||
---
|
||||
Test body.
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
results = registrar.register_commands_for_all_agents(
|
||||
commands=[{"name": "speckit.test-cmd", "file": "test.md"}],
|
||||
source_id="test",
|
||||
source_dir=tmp_path,
|
||||
project_root=tmp_path,
|
||||
)
|
||||
assert "bob" in results, "bob must appear in results for legacy-mode project"
|
||||
registered_file = commands_dir / "speckit.test-cmd.md"
|
||||
assert registered_file.exists(), f"Expected {registered_file} to be written"
|
||||
|
||||
def test_legacy_extension_command_refs_use_dot_separator(self, tmp_path):
|
||||
"""Regression (review #3415): legacy .bob/commands/ extension commands must
|
||||
render Bob 1.x ``/speckit.<cmd>`` refs, not the skills-layout ``/speckit-<cmd>``.
|
||||
|
||||
The single static AGENT_CONFIGS["bob"]["invoke_separator"] is "-" (the
|
||||
default skills layout); register_commands must instead resolve the
|
||||
separator from the project's persisted mode via
|
||||
BobIntegration.invoke_separator_for_mode(False) -> ".".
|
||||
"""
|
||||
import textwrap
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
# Legacy-mode project: .bob/commands exists, ai_skills is NOT set.
|
||||
commands_dir = tmp_path / ".bob" / "commands"
|
||||
commands_dir.mkdir(parents=True)
|
||||
cmd_file = tmp_path / "test.md"
|
||||
cmd_file.write_text(
|
||||
textwrap.dedent("""\
|
||||
---
|
||||
description: "Test command"
|
||||
---
|
||||
See __SPECKIT_COMMAND_SPECIFY__ for details.
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_all_agents(
|
||||
commands=[{"name": "speckit.test-cmd", "file": "test.md"}],
|
||||
source_id="test",
|
||||
source_dir=tmp_path,
|
||||
project_root=tmp_path,
|
||||
)
|
||||
rendered = (commands_dir / "speckit.test-cmd.md").read_text(encoding="utf-8")
|
||||
assert "/speckit.specify" in rendered, (
|
||||
"legacy Bob extension commands must render /speckit.specify (dot)"
|
||||
)
|
||||
assert "/speckit-specify" not in rendered
|
||||
|
||||
|
||||
class TestBobUseFlowPreservesLegacyLayout:
|
||||
"""Regression (review #3415): re-activating an existing Bob 1.x project
|
||||
must not silently migrate it to the skills layout.
|
||||
"""
|
||||
|
||||
def test_update_init_options_preserves_legacy_commands_project(self, tmp_path):
|
||||
"""``use``/``switch``/``upgrade`` on a ``.bob/commands``-only project
|
||||
(no stored ``legacy_commands``) must not write ``ai_skills=True``.
|
||||
"""
|
||||
from specify_cli.integrations._helpers import (
|
||||
_update_init_options_for_integration,
|
||||
)
|
||||
from specify_cli import load_init_options
|
||||
|
||||
# Existing Bob 1.x project: legacy commands dir on disk, no ai_skills.
|
||||
cmds = tmp_path / ".bob" / "commands"
|
||||
cmds.mkdir(parents=True)
|
||||
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
|
||||
bob = get_integration("bob")
|
||||
|
||||
# Simulate the use/switch path: no parsed options were stored.
|
||||
_update_init_options_for_integration(tmp_path, bob, parsed_options=None)
|
||||
|
||||
opts = load_init_options(tmp_path)
|
||||
assert opts.get("ai") == "bob"
|
||||
assert opts.get("ai_skills") is not True, (
|
||||
"an existing .bob/commands project must stay legacy on re-activation"
|
||||
)
|
||||
|
||||
def test_update_init_options_keeps_skills_project_as_skills(self, tmp_path):
|
||||
"""A ``.bob/skills`` project stays skills on re-activation."""
|
||||
from specify_cli.integrations._helpers import (
|
||||
_update_init_options_for_integration,
|
||||
)
|
||||
from specify_cli import load_init_options
|
||||
|
||||
(tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
|
||||
bob = get_integration("bob")
|
||||
|
||||
_update_init_options_for_integration(tmp_path, bob, parsed_options=None)
|
||||
|
||||
opts = load_init_options(tmp_path)
|
||||
assert opts.get("ai_skills") is True
|
||||
|
||||
def test_with_integration_setting_stores_dot_separator_for_legacy(self, tmp_path):
|
||||
"""Regression (review #3415): shared-infra refresh on the use/switch
|
||||
path resolves the command-ref separator *before* init-options are
|
||||
rewritten, via ``effective_invoke_separator``. For an existing
|
||||
``.bob/commands`` project with no stored options this must resolve to
|
||||
``"."`` (project-aware), not the skills-layout ``"-"``; otherwise core
|
||||
command references get rewritten to ``/speckit-*``.
|
||||
"""
|
||||
from specify_cli.integration_runtime import with_integration_setting
|
||||
|
||||
cmds = tmp_path / ".bob" / "commands"
|
||||
cmds.mkdir(parents=True)
|
||||
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
|
||||
bob = get_integration("bob")
|
||||
|
||||
# Simulate the use/switch path: no parsed options stored.
|
||||
settings = with_integration_setting(
|
||||
{}, "bob", bob, parsed_options=None, project_root=tmp_path
|
||||
)
|
||||
assert settings["bob"]["invoke_separator"] == ".", (
|
||||
"legacy .bob/commands project must persist the dot separator so "
|
||||
"shared templates render Bob 1.x /speckit.<cmd> references"
|
||||
)
|
||||
|
||||
def test_use_force_keeps_legacy_command_refs_in_shared_templates(self, tmp_path):
|
||||
"""End-to-end (review #3415): ``integration use bob --force`` on an
|
||||
existing Bob 1.x project (legacy layout on disk, stored options
|
||||
stripped as a pre-PR install would be) must re-render shared templates
|
||||
with ``/speckit.<cmd>`` (dot), not ``/speckit-<cmd>``.
|
||||
"""
|
||||
import json
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
# Create a real legacy Bob project (renders shared templates).
|
||||
target = tmp_path / "proj"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", str(target), "--integration", "bob",
|
||||
"--integration-options", "--legacy-commands",
|
||||
"--ignore-agent-tools", "--script", "sh",
|
||||
])
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
template = target / ".specify" / "templates" / "plan-template.md"
|
||||
assert template.is_file(), "expected a rendered shared plan template"
|
||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
# Simulate a pre-PR Bob 1.x install: no stored options/separator.
|
||||
integ_json = target / ".specify" / "integration.json"
|
||||
data = json.loads(integ_json.read_text(encoding="utf-8"))
|
||||
bob_settings = data["integration_settings"]["bob"]
|
||||
for stale in ("raw_options", "parsed_options", "invoke_separator"):
|
||||
bob_settings.pop(stale, None)
|
||||
integ_json.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
# Re-activate with --force so shared templates are re-rendered.
|
||||
import os
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(target)
|
||||
result = runner.invoke(
|
||||
app, ["integration", "use", "bob", "--force"]
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"use failed: {result.output}"
|
||||
|
||||
rendered = template.read_text(encoding="utf-8")
|
||||
assert "/speckit.plan" in rendered, (
|
||||
"legacy Bob project must keep /speckit.plan (dot) after refresh"
|
||||
)
|
||||
assert "/speckit-plan" not in rendered, (
|
||||
"shared templates must not be rewritten to the skills /speckit-plan"
|
||||
)
|
||||
# And the persisted separator must reflect the legacy layout.
|
||||
data = json.loads(integ_json.read_text(encoding="utf-8"))
|
||||
assert data["integration_settings"]["bob"].get("invoke_separator") == "."
|
||||
|
||||
|
||||
class TestBobCommandRefScopedToActiveAgent:
|
||||
"""Regression (review #3415, 4716424313).
|
||||
|
||||
``CommandRegistrar.register_commands`` runs once per detected agent, but the
|
||||
persisted ``ai_skills`` flag describes only the *active* integration
|
||||
(``opts["ai"]``). When another agent (e.g. Copilot) is active in skills
|
||||
mode while a legacy ``.bob/commands`` layout is also present, Bob's command
|
||||
references must still render with the ``.`` separator (Bob 1.x
|
||||
``/speckit.<cmd>``) rather than inheriting Copilot's ``ai_skills=True`` and
|
||||
rendering ``/speckit-<cmd>``.
|
||||
"""
|
||||
|
||||
def _write_command_ref_ext(self, source_dir):
|
||||
source_dir.mkdir(parents=True, exist_ok=True)
|
||||
cmd = source_dir / "run.md"
|
||||
cmd.write_text(
|
||||
"---\ndescription: Run\n---\n\nUse __SPECKIT_COMMAND_PLAN__ first.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return [{"name": "speckit.ext.run", "file": "run.md"}]
|
||||
|
||||
def test_legacy_bob_ref_not_rewritten_when_other_agent_active_in_skills(
|
||||
self, tmp_path
|
||||
):
|
||||
from specify_cli._init_options import save_init_options
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
# Legacy Bob layout on disk; skills layout absent.
|
||||
(tmp_path / ".bob" / "commands").mkdir(parents=True)
|
||||
# A different agent (Copilot) is the active integration, in skills mode.
|
||||
save_init_options(tmp_path, {"ai": "copilot", "ai_skills": True})
|
||||
|
||||
source_dir = tmp_path / "ext-src"
|
||||
commands = self._write_command_ref_ext(source_dir)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registered = registrar.register_commands(
|
||||
"bob", commands, "ext", source_dir, tmp_path,
|
||||
)
|
||||
assert "speckit.ext.run" in registered
|
||||
|
||||
written = list((tmp_path / ".bob" / "commands").glob("*.md"))
|
||||
assert written, "expected a rendered Bob command file"
|
||||
content = written[0].read_text(encoding="utf-8")
|
||||
assert "__SPECKIT_COMMAND_PLAN__" not in content
|
||||
assert "/speckit.plan" in content, (
|
||||
"legacy Bob command refs must use the dot separator even when "
|
||||
"another agent is active in skills mode"
|
||||
)
|
||||
assert "/speckit-plan" not in content
|
||||
|
||||
def test_active_bob_skills_command_output_uses_dot(self, tmp_path):
|
||||
"""Regression (review #3415, 4724160183, comment 2).
|
||||
|
||||
The separator must match the *output layout* the registrar writes, not
|
||||
the project's persisted ``ai_skills`` flag. Even when Bob itself is the
|
||||
active agent in skills mode, a ``.bob/commands/*.md`` file is a
|
||||
command-layout artifact and must render Bob 1.x ``/speckit.<cmd>``.
|
||||
Rendering ``/speckit-<cmd>`` into a command file (as the old
|
||||
``ai_skills``-driven active-agent branch did) produced an invocation the
|
||||
command layout can't resolve. Bob skills are written via its own
|
||||
skills path, so ``register_commands`` only ever emits command-layout
|
||||
files for Bob.
|
||||
"""
|
||||
from specify_cli._init_options import save_init_options
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
(tmp_path / ".bob" / "skills").mkdir(parents=True)
|
||||
save_init_options(tmp_path, {"ai": "bob", "ai_skills": True})
|
||||
|
||||
source_dir = tmp_path / "ext-src"
|
||||
commands = self._write_command_ref_ext(source_dir)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands("bob", commands, "ext", source_dir, tmp_path)
|
||||
|
||||
written = list((tmp_path / ".bob" / "commands").glob("*.md"))
|
||||
assert written, "expected a rendered Bob command file"
|
||||
content = written[0].read_text(encoding="utf-8")
|
||||
assert "__SPECKIT_COMMAND_PLAN__" not in content
|
||||
assert "/speckit.plan" in content, (
|
||||
"a .bob/commands/*.md command-layout file must use the dot "
|
||||
"separator even when Bob is the active agent in skills mode"
|
||||
)
|
||||
assert "/speckit-plan" not in content
|
||||
|
||||
def test_inactive_bob_command_output_uses_dot_even_with_skills_dir(
|
||||
self, tmp_path
|
||||
):
|
||||
"""Regression (review #3415, 4723246468): for an inactive Bob install
|
||||
the registrar's separator must match the layout it is actually writing
|
||||
(``.bob/commands/*.md`` — command layout), not on-disk sibling dirs.
|
||||
Even when a ``.bob/skills/`` directory (with managed ``speckit-*``
|
||||
skills) coexists, command-layout files must keep ``/speckit.<cmd>``.
|
||||
"""
|
||||
from specify_cli._init_options import save_init_options
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
# Both layouts on disk; the active agent is something else entirely.
|
||||
(tmp_path / ".bob" / "commands").mkdir(parents=True)
|
||||
(tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
|
||||
save_init_options(tmp_path, {"ai": "claude", "ai_skills": True})
|
||||
|
||||
source_dir = tmp_path / "ext-src"
|
||||
commands = self._write_command_ref_ext(source_dir)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands("bob", commands, "ext", source_dir, tmp_path)
|
||||
|
||||
written = list((tmp_path / ".bob" / "commands").glob("*.md"))
|
||||
assert written, "expected a rendered Bob command file"
|
||||
content = written[0].read_text(encoding="utf-8")
|
||||
assert "__SPECKIT_COMMAND_PLAN__" not in content
|
||||
assert "/speckit.plan" in content, (
|
||||
"Bob command-layout output must use the dot separator regardless "
|
||||
"of a coexisting .bob/skills directory"
|
||||
)
|
||||
assert "/speckit-plan" not in content
|
||||
|
||||
|
||||
class TestBobSetupPreservesLegacyOnUpgrade:
|
||||
"""Regression (review #3415, 4723782860, comment 1).
|
||||
|
||||
``setup()`` must apply the same managed-artifact detection as ``use`` so
|
||||
that ``integration upgrade bob`` on a Bob 1.x install (managed
|
||||
``.bob/commands/speckit.*.md`` on disk, no stored options) preserves the
|
||||
command layout instead of silently generating skills and stale-deleting
|
||||
the legacy commands.
|
||||
"""
|
||||
|
||||
def test_setup_without_options_preserves_existing_command_layout(
|
||||
self, tmp_path
|
||||
):
|
||||
from specify_cli.integrations.bob import BobIntegration
|
||||
|
||||
# Pre-existing Bob 1.x install: managed command files, no options.
|
||||
cmds = tmp_path / ".bob" / "commands"
|
||||
cmds.mkdir(parents=True)
|
||||
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
|
||||
|
||||
bob = BobIntegration()
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
with pytest.warns(UserWarning, match="Bob legacy commands mode"):
|
||||
created = bob.setup(tmp_path, m, parsed_options=None)
|
||||
|
||||
# Command layout regenerated; no skills layout introduced.
|
||||
assert not (tmp_path / ".bob" / "skills").exists(), (
|
||||
"upgrade must not migrate an existing legacy Bob project to skills"
|
||||
)
|
||||
assert created, "expected command files to be regenerated"
|
||||
for f in created:
|
||||
assert f.parent == tmp_path / ".bob" / "commands"
|
||||
assert f.suffix == ".md"
|
||||
|
||||
def test_setup_fresh_project_still_defaults_to_skills(self, tmp_path):
|
||||
"""A fresh project (no managed artifacts) still defaults to skills."""
|
||||
from specify_cli.integrations.bob import BobIntegration
|
||||
|
||||
bob = BobIntegration()
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
created = bob.setup(tmp_path, m, parsed_options=None)
|
||||
|
||||
assert (tmp_path / ".bob" / "skills").is_dir()
|
||||
assert not (tmp_path / ".bob" / "commands").exists()
|
||||
assert created
|
||||
|
||||
def test_setup_with_skills_flag_migrates_legacy_to_skills(self, tmp_path):
|
||||
"""Review #3415, 4724160183, comment 1: ``--skills`` on an existing
|
||||
legacy install forces the skills layout (the migration opt-in), instead
|
||||
of preserving the auto-detected legacy layout. ``setup()`` scaffolds the
|
||||
skills layout; the ``integration upgrade`` stale-file pass removes the
|
||||
old command files.
|
||||
"""
|
||||
from specify_cli.integrations.bob import BobIntegration
|
||||
|
||||
# Pre-existing Bob 1.x install on disk.
|
||||
cmds = tmp_path / ".bob" / "commands"
|
||||
cmds.mkdir(parents=True)
|
||||
(cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
|
||||
|
||||
bob = BobIntegration()
|
||||
m = IntegrationManifest("bob", tmp_path)
|
||||
# No deprecation warning — the user opted into skills, not legacy.
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", UserWarning)
|
||||
created = bob.setup(tmp_path, m, parsed_options={"skills": True})
|
||||
|
||||
assert (tmp_path / ".bob" / "skills").is_dir(), (
|
||||
"--skills must force the skills layout even when a legacy commands "
|
||||
"layout is already on disk"
|
||||
)
|
||||
assert created
|
||||
for f in created:
|
||||
assert f.name == "SKILL.md"
|
||||
assert f.parent.name.startswith("speckit-")
|
||||
|
||||
|
||||
class TestBobPostProcessSkillContent:
|
||||
"""Regression (review #3415, 4723782860, comment 2).
|
||||
|
||||
Preset/extension skill generators call ``post_process_skill_content`` on
|
||||
the *registered* ``BobIntegration`` instance. Core Bob skills are
|
||||
intent-activated and intentionally omit the shared slash-command hook note,
|
||||
so the registered class must expose the same no-op the skills helper does
|
||||
(not inherit a note-injecting default) to keep every skill path consistent.
|
||||
"""
|
||||
|
||||
def test_registered_bob_has_post_process_hook(self):
|
||||
bob = get_integration("bob")
|
||||
assert hasattr(bob, "post_process_skill_content")
|
||||
|
||||
def test_post_process_is_noop_no_hook_note_injected(self):
|
||||
bob = get_integration("bob")
|
||||
sample = (
|
||||
"---\nname: speckit-plan\n---\n\n"
|
||||
"Run /speckit.plan then /speckit.tasks.\n"
|
||||
)
|
||||
assert bob.post_process_skill_content(sample) == sample
|
||||
|
||||
def test_post_process_matches_skills_helper(self):
|
||||
from specify_cli.integrations.bob import _BobSkillsHelper
|
||||
|
||||
bob = get_integration("bob")
|
||||
sample = "---\nname: speckit-analyze\n---\n\nSome body with /speckit.plan.\n"
|
||||
assert (
|
||||
bob.post_process_skill_content(sample)
|
||||
== _BobSkillsHelper().post_process_skill_content(sample)
|
||||
)
|
||||
|
||||
@@ -922,6 +922,57 @@ class TestCatalogSourceManagement:
|
||||
assert str(cfg_path) in message
|
||||
assert "expected a mapping" in message
|
||||
|
||||
def test_add_catalog_rejects_inf_priority_in_existing_entry(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
# ``priority: .inf`` loads as float('inf'); int() on it raises
|
||||
# OverflowError, which used to escape the IntegrationValidationError
|
||||
# contract as a raw traceback (github/spec-kit#3526 fixed the sibling
|
||||
# workflow/step loaders the same way).
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": "https://a.example.com/catalog.json",
|
||||
"priority": float("inf"),
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="must be an integer"
|
||||
):
|
||||
cat.add_catalog("https://new.example.com/catalog.json")
|
||||
|
||||
def test_remove_catalog_tolerates_inf_priority(self, tmp_path, monkeypatch):
|
||||
# Building the remove display order must not crash on a ``priority:
|
||||
# .inf`` entry; it falls back to positional order like the other
|
||||
# non-integer priorities do.
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": "https://a.example.com/catalog.json",
|
||||
"priority": float("inf"),
|
||||
},
|
||||
{"url": "https://b.example.com/catalog.json", "priority": 2},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.remove_catalog(0) # must not raise OverflowError
|
||||
|
||||
def test_add_catalog_skips_blank_url_entries(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
|
||||
@@ -575,6 +575,17 @@ class TestCopilotSkillsMode:
|
||||
assert copilot.effective_invoke_separator({"skills": True}) == "-"
|
||||
assert copilot.effective_invoke_separator({"skills": False}) == "."
|
||||
|
||||
def test_invoke_separator_for_mode_tracks_persisted_state(self):
|
||||
"""Regression (review #3415): registration paths (preset/extension
|
||||
command refs) must resolve the separator from the persisted ai_skills
|
||||
state. A Copilot skills project renders ``/speckit-<cmd>`` (hyphen),
|
||||
matching ``build_command_invocation``; the default markdown layout
|
||||
renders ``/speckit.<cmd>`` (dot).
|
||||
"""
|
||||
copilot = self._make_copilot()
|
||||
assert copilot.invoke_separator_for_mode(True) == "-"
|
||||
assert copilot.invoke_separator_for_mode(False) == "."
|
||||
|
||||
def test_skill_body_has_content(self, tmp_path):
|
||||
"""Each SKILL.md body should contain template content."""
|
||||
copilot = self._make_copilot()
|
||||
|
||||
@@ -15,6 +15,22 @@ from tests.conftest import strip_ansi
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
["init", "--help"],
|
||||
["integration", "install", "--help"],
|
||||
["integration", "switch", "--help"],
|
||||
["integration", "upgrade", "--help"],
|
||||
],
|
||||
)
|
||||
def test_script_help_includes_python_variant(args):
|
||||
result = runner.invoke(app, args)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "sh, ps, or py" in " ".join(strip_ansi(result.output).split())
|
||||
|
||||
|
||||
def _init_project(tmp_path, integration="copilot", integration_options=None):
|
||||
"""Helper: init a spec-kit project with the given integration."""
|
||||
project = tmp_path / "proj"
|
||||
@@ -2477,6 +2493,300 @@ class TestIntegrationUpgrade:
|
||||
f"found: {[f.name for f in core_remaining]}"
|
||||
)
|
||||
|
||||
def test_upgrade_bob_skills_migration_preserves_manifest(self, tmp_path):
|
||||
"""Regression (review #3415, 4724160183, comment 1).
|
||||
|
||||
``integration upgrade bob --integration-options="--skills"`` migrates a
|
||||
legacy Bob 1.x install (``.bob/commands/*.md``) to the skills layout
|
||||
(``.bob/skills/speckit-*/SKILL.md``) and stale-removes the old command
|
||||
files. Because that stale-file pass shrinks the tracked set, the
|
||||
upgrade's Phase 2 must NOT delete the freshly-saved ``bob.manifest.json``
|
||||
— otherwise the migrated project is left untracked and un-upgradeable.
|
||||
"""
|
||||
project = _init_project(
|
||||
tmp_path, "bob", integration_options="--legacy-commands"
|
||||
)
|
||||
|
||||
commands = project / ".bob" / "commands"
|
||||
skills = project / ".bob" / "skills"
|
||||
manifest_path = (
|
||||
project / ".specify" / "integrations" / "bob.manifest.json"
|
||||
)
|
||||
assert commands.is_dir() and sorted(commands.glob("speckit.*.md"))
|
||||
assert not skills.exists()
|
||||
assert manifest_path.is_file()
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "bob",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--force",
|
||||
])
|
||||
assert result.exit_code == 0, f"migration upgrade failed: {result.output}"
|
||||
|
||||
# Skills layout scaffolded; legacy core command files removed.
|
||||
assert skills.is_dir(), ".bob/skills/ must exist after --skills migration"
|
||||
assert sorted(skills.glob("speckit-*")), "expected migrated skill dirs"
|
||||
core_commands = [
|
||||
f for f in commands.glob("speckit.*.md")
|
||||
if "agent-context" not in f.name
|
||||
] if commands.exists() else []
|
||||
assert core_commands == [], (
|
||||
f"legacy core command files should be removed, found: "
|
||||
f"{[f.name for f in core_commands]}"
|
||||
)
|
||||
|
||||
# The manifest must survive so the project stays tracked/upgradeable.
|
||||
assert manifest_path.is_file(), (
|
||||
"bob.manifest.json must survive a layout-shrinking migration"
|
||||
)
|
||||
reupgrade = _run_in_project(project, [
|
||||
"integration", "upgrade", "bob", "--script", "sh", "--force",
|
||||
])
|
||||
assert reupgrade.exit_code == 0, (
|
||||
f"migrated project must remain upgradeable: {reupgrade.output}"
|
||||
)
|
||||
|
||||
def test_upgrade_bob_layout_change_reconciles_extension_artifacts(self, tmp_path):
|
||||
"""Regression (review #3415, 4725829110).
|
||||
|
||||
When a dual-mode agent (Bob) flips layout across an upgrade, the old
|
||||
layout's *extension* artifacts must be reconciled — not left orphaned.
|
||||
A legacy Bob install renders enabled extensions as ``.bob/commands/``
|
||||
command files; migrating to skills via ``--skills`` must remove those
|
||||
command files, recreate the extension as ``.bob/skills/`` skills, and
|
||||
update the extension registry accordingly (and vice-versa for the
|
||||
reverse ``--legacy-commands`` migration).
|
||||
"""
|
||||
project = _init_project(
|
||||
tmp_path, "bob", integration_options="--legacy-commands"
|
||||
)
|
||||
|
||||
result = _run_in_project(project, ["extension", "add", "git"])
|
||||
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
||||
|
||||
commands = project / ".bob" / "commands"
|
||||
skills = project / ".bob" / "skills"
|
||||
registry_path = project / ".specify" / "extensions" / ".registry"
|
||||
|
||||
def _git_registry():
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
g = data["extensions"]["git"]
|
||||
return list(g.get("registered_commands", {})), g.get(
|
||||
"registered_skills", []
|
||||
)
|
||||
|
||||
# Legacy precondition: git renders as command files under .bob/commands.
|
||||
assert sorted(commands.glob("speckit.git.*.md")), (
|
||||
"legacy Bob should render the git extension as command files"
|
||||
)
|
||||
assert not list(skills.glob("speckit-git-*")) if skills.exists() else True
|
||||
cmds_agents, skill_names = _git_registry()
|
||||
assert "bob" in cmds_agents and not skill_names
|
||||
|
||||
# Migrate legacy -> skills.
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "bob",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--force",
|
||||
])
|
||||
assert result.exit_code == 0, f"--skills migration failed: {result.output}"
|
||||
|
||||
# Old-layout git command files removed; skills recreated.
|
||||
assert not sorted(commands.glob("speckit.git.*.md")), (
|
||||
"git extension command files must be removed after --skills migration"
|
||||
)
|
||||
assert sorted(skills.glob("speckit-git-*")), (
|
||||
"git extension must be recreated as skills after --skills migration"
|
||||
)
|
||||
cmds_agents, skill_names = _git_registry()
|
||||
assert "bob" not in cmds_agents, (
|
||||
"extension registry must drop the stale bob command entry"
|
||||
)
|
||||
assert skill_names, "extension registry must record the migrated skills"
|
||||
|
||||
# Migrate skills -> legacy: the reverse reconciliation must also hold.
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "bob",
|
||||
"--integration-options", "--legacy-commands",
|
||||
"--script", "sh", "--force",
|
||||
])
|
||||
assert result.exit_code == 0, (
|
||||
f"--legacy-commands migration failed: {result.output}"
|
||||
)
|
||||
assert not sorted(skills.glob("speckit-git-*")), (
|
||||
"git extension skills must be removed after --legacy-commands migration"
|
||||
)
|
||||
assert sorted(commands.glob("speckit.git.*.md")), (
|
||||
"git extension command files must be recreated in legacy layout"
|
||||
)
|
||||
cmds_agents, skill_names = _git_registry()
|
||||
assert "bob" in cmds_agents and not skill_names
|
||||
|
||||
def test_upgrade_bob_layout_change_rejected_with_presets_installed(self, tmp_path):
|
||||
"""Regression (review #3415, 4726193915).
|
||||
|
||||
A command↔skills layout change cannot reconcile preset artifacts (no
|
||||
agent-scoped preset re-registration exists). Rather than silently
|
||||
orphaning preset files / leaving the registry inconsistent, a
|
||||
layout-changing ``upgrade`` must reject the migration with an
|
||||
actionable error *before any mutation* when preset overrides are
|
||||
installed for the agent. A same-layout upgrade must still succeed.
|
||||
"""
|
||||
project = _init_project(
|
||||
tmp_path, "bob", integration_options="--legacy-commands"
|
||||
)
|
||||
commands = project / ".bob" / "commands"
|
||||
skills = project / ".bob" / "skills"
|
||||
assert sorted(commands.glob("speckit.*.md"))
|
||||
|
||||
# Simulate an installed preset that registered command overrides for bob.
|
||||
presets_dir = project / ".specify" / "presets"
|
||||
presets_dir.mkdir(parents=True, exist_ok=True)
|
||||
(presets_dir / ".registry").write_text(
|
||||
json.dumps({
|
||||
"presets": {
|
||||
"my-preset": {
|
||||
"version": "1.0.0",
|
||||
"enabled": True,
|
||||
"registered_commands": {"bob": ["speckit.plan"]},
|
||||
"registered_skills": [],
|
||||
}
|
||||
}
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Layout-changing upgrade is rejected, and nothing is mutated.
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "bob",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--force",
|
||||
])
|
||||
assert result.exit_code != 0, "layout change with presets must be rejected"
|
||||
assert "preset" in result.output.lower()
|
||||
assert "my-preset" in result.output
|
||||
assert not skills.exists(), "no skills layout must be scaffolded on rejection"
|
||||
assert sorted(commands.glob("speckit.*.md")), (
|
||||
"legacy command files must be left untouched on rejection"
|
||||
)
|
||||
|
||||
# A same-layout upgrade (no flag) must still succeed with presets present.
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "bob", "--script", "sh", "--force",
|
||||
])
|
||||
assert result.exit_code == 0, (
|
||||
f"same-layout upgrade must not be blocked by presets: {result.output}"
|
||||
)
|
||||
|
||||
def test_upgrade_bob_layout_change_rejected_when_preset_registry_unreadable(
|
||||
self, tmp_path
|
||||
):
|
||||
"""Regression (review #3415, 4744636079).
|
||||
|
||||
The preset guard must fail *closed*: if the preset registry exists but
|
||||
cannot be read/parsed (corruption, permissions), the layout-changing
|
||||
upgrade must be rejected before any mutation rather than proceeding on
|
||||
a false "no presets installed" assumption (which would let ``--force``
|
||||
delete preset-overridden command files while their registry state is
|
||||
unknown). A genuinely absent registry must still be allowed.
|
||||
"""
|
||||
project = _init_project(
|
||||
tmp_path, "bob", integration_options="--legacy-commands"
|
||||
)
|
||||
commands = project / ".bob" / "commands"
|
||||
skills = project / ".bob" / "skills"
|
||||
assert sorted(commands.glob("speckit.*.md"))
|
||||
|
||||
# Corrupted (unparseable) registry: exists but cannot be read as JSON.
|
||||
presets_dir = project / ".specify" / "presets"
|
||||
presets_dir.mkdir(parents=True, exist_ok=True)
|
||||
(presets_dir / ".registry").write_text("{ not valid json", encoding="utf-8")
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "bob",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--force",
|
||||
])
|
||||
assert result.exit_code != 0, (
|
||||
"layout change must be rejected when preset registry is unreadable"
|
||||
)
|
||||
assert "preset registry" in result.output.lower()
|
||||
assert not skills.exists(), "no skills layout may be scaffolded on rejection"
|
||||
assert sorted(commands.glob("speckit.*.md")), (
|
||||
"legacy command files must be untouched when failing closed"
|
||||
)
|
||||
|
||||
# A valid, empty registry must NOT block the migration.
|
||||
(presets_dir / ".registry").write_text(
|
||||
json.dumps({"presets": {}}), encoding="utf-8"
|
||||
)
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "bob",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--force",
|
||||
])
|
||||
assert result.exit_code == 0, (
|
||||
f"valid empty preset registry must not block migration: {result.output}"
|
||||
)
|
||||
assert skills.exists(), "skills layout should be scaffolded once unblocked"
|
||||
|
||||
def test_upgrade_secondary_bob_layout_change_preserves_active_agent_skills(
|
||||
self, tmp_path
|
||||
):
|
||||
"""Regression (review #3415, 4726347306).
|
||||
|
||||
``integration upgrade`` supports upgrading a *secondary* (non-active)
|
||||
integration. The layout-change extension reconciliation must NOT run
|
||||
for a secondary agent: ``unregister_agent_artifacts`` treats the
|
||||
unscoped per-extension ``registered_skills`` as belonging to the passed
|
||||
agent and, if that agent's skills dir is absent, scans every agent's
|
||||
skills dir — which could delete/untrack the *active* agent's extension
|
||||
skills. The following re-registration cannot repair that because
|
||||
extension skill rendering is active-agent-scoped (#2948).
|
||||
"""
|
||||
# Active agent: copilot in skills mode → git extension renders as skills.
|
||||
project = _init_project(tmp_path, "copilot", integration_options="--skills")
|
||||
result = _run_in_project(project, ["extension", "add", "git"])
|
||||
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
||||
|
||||
skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
|
||||
assert skill.exists(), "precondition: active copilot has the git extension skill"
|
||||
|
||||
registry_path = project / ".specify" / "extensions" / ".registry"
|
||||
|
||||
def _git_skills():
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
return data["extensions"]["git"].get("registered_skills", [])
|
||||
|
||||
assert _git_skills(), "precondition: git skills registered for active copilot"
|
||||
|
||||
# Add a secondary (non-active) Bob in the legacy commands layout.
|
||||
result = _run_in_project(project, [
|
||||
"integration", "install", "bob",
|
||||
"--integration-options", "--legacy-commands",
|
||||
"--script", "sh", "--force",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# Flip the *secondary* Bob's layout to skills. copilot stays active.
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "bob",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--force",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# The active agent's extension skill must be untouched on disk and in
|
||||
# the registry — the secondary layout change must not reconcile it.
|
||||
assert skill.exists(), (
|
||||
"secondary Bob layout change must not delete the active agent's "
|
||||
"extension skill"
|
||||
)
|
||||
assert _git_skills(), (
|
||||
"secondary Bob layout change must not untrack the active agent's "
|
||||
"extension skills in the registry"
|
||||
)
|
||||
|
||||
def test_upgrade_preserves_existing_vscode_settings(self, tmp_path):
|
||||
"""Regression: copilot upgrade must not stale-delete .vscode/settings.json.
|
||||
|
||||
@@ -2616,6 +2926,82 @@ class TestIntegrationUpgrade:
|
||||
"deleted extension skill (#2886)"
|
||||
)
|
||||
|
||||
def test_installed_presets_affecting_agent_absent_vs_unreadable(self, tmp_path):
|
||||
"""Unit (review #3415, 4744636079): fail closed only when unreadable.
|
||||
|
||||
The preset guard helper must return an empty list for a genuinely
|
||||
absent registry, but raise ``_PresetRegistryUnreadableError`` when the
|
||||
registry exists yet cannot be read/parsed — so a layout-changing
|
||||
upgrade never proceeds on a false "no presets" result.
|
||||
"""
|
||||
from specify_cli.integrations._migrate_commands import (
|
||||
_PresetRegistryUnreadableError,
|
||||
_installed_presets_affecting_agent,
|
||||
)
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
|
||||
# Genuinely absent registry → empty list (safe to proceed).
|
||||
assert _installed_presets_affecting_agent(project, "bob") == []
|
||||
|
||||
presets_dir = project / ".specify" / "presets"
|
||||
presets_dir.mkdir(parents=True)
|
||||
registry = presets_dir / ".registry"
|
||||
|
||||
# Corrupted JSON → unreadable → raise.
|
||||
registry.write_text("{ not json", encoding="utf-8")
|
||||
with pytest.raises(_PresetRegistryUnreadableError):
|
||||
_installed_presets_affecting_agent(project, "bob")
|
||||
|
||||
# Malformed structure (presets not a dict) → unreadable → raise.
|
||||
registry.write_text(json.dumps({"presets": []}), encoding="utf-8")
|
||||
with pytest.raises(_PresetRegistryUnreadableError):
|
||||
_installed_presets_affecting_agent(project, "bob")
|
||||
|
||||
# Malformed per-preset entry (not a dict) → ownership unknown → raise.
|
||||
registry.write_text(
|
||||
json.dumps({"presets": {"p1": []}}), encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(_PresetRegistryUnreadableError):
|
||||
_installed_presets_affecting_agent(project, "bob")
|
||||
|
||||
# Malformed registered_commands (not a dict) → raise.
|
||||
registry.write_text(
|
||||
json.dumps({"presets": {"p1": {"registered_commands": []}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(_PresetRegistryUnreadableError):
|
||||
_installed_presets_affecting_agent(project, "bob")
|
||||
|
||||
# Malformed registered_skills (not a list) → raise.
|
||||
registry.write_text(
|
||||
json.dumps({"presets": {"p1": {"registered_skills": {}}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(_PresetRegistryUnreadableError):
|
||||
_installed_presets_affecting_agent(project, "bob")
|
||||
|
||||
# Valid, empty registry → empty list.
|
||||
registry.write_text(json.dumps({"presets": {}}), encoding="utf-8")
|
||||
assert _installed_presets_affecting_agent(project, "bob") == []
|
||||
|
||||
# Valid registry with a preset registered for bob → reported.
|
||||
registry.write_text(
|
||||
json.dumps({
|
||||
"presets": {
|
||||
"p1": {"registered_commands": {"bob": ["speckit.plan"]}},
|
||||
"p2": {"registered_commands": {"codex": ["speckit.plan"]}},
|
||||
"p3": {"registered_skills": ["speckit-x"]},
|
||||
}
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert sorted(_installed_presets_affecting_agent(project, "bob")) == [
|
||||
"p1",
|
||||
"p3",
|
||||
]
|
||||
|
||||
|
||||
# ── Full lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -220,6 +220,28 @@ class TestManifestUninstall:
|
||||
m.uninstall()
|
||||
assert not m.manifest_path.exists()
|
||||
|
||||
def test_remove_manifest_false_preserves_manifest_file(self, tmp_path):
|
||||
"""Regression (review #3415, 4724160183): a partial cleanup must not
|
||||
delete ``{key}.manifest.json``.
|
||||
|
||||
The upgrade stale-file pass builds a throwaway manifest sharing the
|
||||
integration's key over a subset of files and uninstalls it. With
|
||||
``remove_manifest=False`` the tracked files are still removed but the
|
||||
real, freshly-saved manifest for that key survives — otherwise a
|
||||
layout-shrinking upgrade (e.g. Bob migrating legacy commands → skills)
|
||||
would leave the integration untracked and un-upgradeable.
|
||||
"""
|
||||
m = IntegrationManifest("test", tmp_path, version="1.0")
|
||||
m.record_file("f.txt", "content")
|
||||
m.save()
|
||||
assert m.manifest_path.exists()
|
||||
removed, skipped = m.uninstall(remove_manifest=False)
|
||||
assert len(removed) == 1
|
||||
assert not (tmp_path / "f.txt").exists()
|
||||
assert m.manifest_path.exists(), (
|
||||
"remove_manifest=False must keep the manifest file on disk"
|
||||
)
|
||||
|
||||
def test_cleans_empty_parent_dirs(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("a/b/c/f.txt", "content")
|
||||
|
||||
133
tests/parity_helpers.py
Normal file
133
tests/parity_helpers.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Shared helpers for the core-script Python parity tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
BASH_DIR = PROJECT_ROOT / "scripts" / "bash"
|
||||
PS_DIR = PROJECT_ROOT / "scripts" / "powershell"
|
||||
PY_DIR = PROJECT_ROOT / "scripts" / "python"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
WINDOWS_POWERSHELL = (
|
||||
(shutil.which("powershell.exe") or shutil.which("powershell"))
|
||||
if os.name == "nt"
|
||||
else None
|
||||
)
|
||||
POWERSHELL_EXE = "pwsh" if HAS_PWSH else WINDOWS_POWERSHELL
|
||||
HAS_POWERSHELL = POWERSHELL_EXE is not None
|
||||
|
||||
|
||||
def make_repo(tmp_path: Path, name: str = "proj") -> Path:
|
||||
repo = tmp_path / name
|
||||
(repo / ".specify").mkdir(parents=True)
|
||||
return repo
|
||||
|
||||
|
||||
def install_scripts(repo: Path, script: str) -> None:
|
||||
"""Install the bash/powershell/python twins of a kebab-case script name."""
|
||||
py_name = script.replace("-", "_")
|
||||
|
||||
bash_dir = repo / ".specify" / "scripts" / "bash"
|
||||
bash_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(BASH_DIR / "common.sh", bash_dir / "common.sh")
|
||||
shutil.copy(BASH_DIR / f"{script}.sh", bash_dir / f"{script}.sh")
|
||||
|
||||
ps_dir = repo / ".specify" / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(PS_DIR / "common.ps1", ps_dir / "common.ps1")
|
||||
shutil.copy(PS_DIR / f"{script}.ps1", ps_dir / f"{script}.ps1")
|
||||
|
||||
py_dir = repo / ".specify" / "scripts" / "python"
|
||||
py_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(PY_DIR / "common.py", py_dir / "common.py")
|
||||
shutil.copy(PY_DIR / f"{py_name}.py", py_dir / f"{py_name}.py")
|
||||
|
||||
|
||||
def bash_cmd(repo: Path, script: str, *args: str) -> list[str]:
|
||||
return ["bash", str(repo / ".specify" / "scripts" / "bash" / f"{script}.sh"), *args]
|
||||
|
||||
|
||||
def py_cmd(repo: Path, script: str, *args: str) -> list[str]:
|
||||
py_name = script.replace("-", "_")
|
||||
return [
|
||||
sys.executable,
|
||||
str(repo / ".specify" / "scripts" / "python" / f"{py_name}.py"),
|
||||
*args,
|
||||
]
|
||||
|
||||
|
||||
def ps_cmd(repo: Path, script: str, *args: str) -> list[str]:
|
||||
assert POWERSHELL_EXE, "no PowerShell available; guard the test with HAS_POWERSHELL"
|
||||
return [
|
||||
POWERSHELL_EXE,
|
||||
"-NoProfile",
|
||||
"-File",
|
||||
str(repo / ".specify" / "scripts" / "powershell" / f"{script}.ps1"),
|
||||
*args,
|
||||
]
|
||||
|
||||
|
||||
def clean_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
if key.startswith("SPECIFY_"):
|
||||
env.pop(key)
|
||||
return env
|
||||
|
||||
|
||||
def run(
|
||||
cmd: list[str], repo: Path, env: dict[str, str] | None = None
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env if env is not None else clean_env(),
|
||||
)
|
||||
|
||||
|
||||
def json_stdout(result: subprocess.CompletedProcess[str]) -> object:
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def write_feature_json(
|
||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
||||
) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}, separators=(",", ":"))
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def normalize_repo_paths(text: str, repo: Path) -> str:
|
||||
"""Replace the repo path with a placeholder so two-repo runs compare equal."""
|
||||
repo_paths = sorted({str(repo), str(repo.resolve())}, key=len, reverse=True)
|
||||
for repo_path in repo_paths:
|
||||
text = text.replace(repo_path, "<REPO>")
|
||||
return text.replace("\r\n", "\n")
|
||||
|
||||
|
||||
def normalize_script_names(text: str, repo: Path, script: str) -> str:
|
||||
"""Replace per-runtime script paths (argv[0] in usage/help output)."""
|
||||
py_name = script.replace("-", "_")
|
||||
bash_script = str(repo / ".specify" / "scripts" / "bash" / f"{script}.sh")
|
||||
py_script = str(repo / ".specify" / "scripts" / "python" / f"{py_name}.py")
|
||||
return text.replace(bash_script, "<SCRIPT>").replace(py_script, "<SCRIPT>")
|
||||
|
||||
|
||||
def normalize_status_text(text: str) -> str:
|
||||
return (
|
||||
text.replace(" ✓ ", " [OK] ")
|
||||
.replace(" ✗ ", " [FAIL] ")
|
||||
.replace("\r\n", "\n")
|
||||
)
|
||||
@@ -296,6 +296,39 @@ def test_python_normal_mode_persists_feature_json(prereq_repo: Path) -> None:
|
||||
assert data["feature_directory"] == "specs/002-other"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_persisted_feature_json_is_lexical_when_specs_is_symlink(
|
||||
prereq_repo: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""A symlinked specs/ dir must persist "specs/NNN" like Bash does with its
|
||||
lexical prefix strip — resolve() would escape the repo and store a
|
||||
machine-specific absolute path."""
|
||||
real_specs = tmp_path / "real-specs"
|
||||
feat = real_specs / "002-other"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
repo = prereq_repo.resolve()
|
||||
try:
|
||||
(repo / "specs").symlink_to(real_specs, target_is_directory=True)
|
||||
except OSError:
|
||||
pytest.skip("symlinks not supported on this platform")
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = str(repo / "specs" / "002-other")
|
||||
feature_json = repo / ".specify" / "feature.json"
|
||||
|
||||
bash = _run(_bash_cmd(prereq_repo, "--json"), prereq_repo, env=env)
|
||||
assert bash.returncode == 0, bash.stderr
|
||||
bash_persisted = json.loads(feature_json.read_text(encoding="utf-8"))
|
||||
feature_json.unlink()
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json"), prereq_repo, env=env)
|
||||
assert py.returncode == 0, py.stderr
|
||||
py_persisted = json.loads(feature_json.read_text(encoding="utf-8"))
|
||||
|
||||
assert py_persisted == bash_persisted
|
||||
assert py_persisted["feature_directory"] == "specs/002-other"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "expected"),
|
||||
[
|
||||
|
||||
@@ -10,12 +10,18 @@ and ``process_template`` turns them into a valid Python invocation
|
||||
existence check below enforces that ordering.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
from tests.parity_helpers import HAS_POWERSHELL, POWERSHELL_EXE
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
TEMPLATES_DIR = REPO_ROOT / "templates" / "commands"
|
||||
@@ -77,6 +83,122 @@ def test_template_renders_python_invocation(name: str):
|
||||
), f"{name} did not render a Python invocation"
|
||||
|
||||
|
||||
def test_py_missing_variant_rejects_opposite_shell_only():
|
||||
opposite_variant = "sh" if os.name == "nt" else "ps"
|
||||
opposite_command = (
|
||||
"scripts/bash/setup-plan.sh --json"
|
||||
if opposite_variant == "sh"
|
||||
else "scripts/powershell/setup-plan.ps1 -Json"
|
||||
)
|
||||
content = """---
|
||||
scripts:
|
||||
{variant}: {command}
|
||||
---
|
||||
Run {{SCRIPT}} now.
|
||||
""".format(variant=opposite_variant, command=opposite_command)
|
||||
|
||||
with pytest.raises(ValueError, match="No runnable script variant"):
|
||||
IntegrationBase.process_template(content, "agent", "py")
|
||||
|
||||
|
||||
def test_missing_script_preference_keeps_available_shell(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.platform.system", lambda: "Windows"
|
||||
)
|
||||
|
||||
selected = IntegrationBase.select_script_variant(
|
||||
None, {"sh": "scripts/bash/setup-plan.sh --json"}
|
||||
)
|
||||
|
||||
assert selected == "sh"
|
||||
|
||||
|
||||
def test_spaced_python_interpreter_uses_powershell_call_operator(monkeypatch):
|
||||
interpreter = r"C:\Program Files\Py$thon's\python.exe"
|
||||
quoted_interpreter = interpreter.replace("'", "''")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable",
|
||||
interpreter,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.os", SimpleNamespace(name="nt")
|
||||
)
|
||||
|
||||
content = "---\nscripts:\n py: scripts/python/setup_plan.py --json\n---\n{SCRIPT}\n"
|
||||
result = IntegrationBase.process_template(content, "agent", "py")
|
||||
|
||||
assert (
|
||||
f"& '{quoted_interpreter}' "
|
||||
".specify/scripts/python/setup_plan.py --json"
|
||||
) in result
|
||||
|
||||
|
||||
def test_spaced_python_interpreter_uses_posix_shell_quoting(monkeypatch):
|
||||
interpreter = "/opt/Python $HOME's/bin/python"
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable",
|
||||
interpreter,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.os", SimpleNamespace(name="posix")
|
||||
)
|
||||
|
||||
content = "---\nscripts:\n py: scripts/python/setup_plan.py --json\n---\n{SCRIPT}\n"
|
||||
result = IntegrationBase.process_template(content, "agent", "py")
|
||||
|
||||
assert (
|
||||
f"{shlex.quote(interpreter)} "
|
||||
".specify/scripts/python/setup_plan.py --json"
|
||||
) in result
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_spaced_python_interpreter_invocation_runs_in_powershell(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
interpreter_dir = tmp_path / "Python With Spaces"
|
||||
interpreter_dir.mkdir()
|
||||
if os.name == "nt":
|
||||
interpreter = interpreter_dir / "python.cmd"
|
||||
interpreter.write_text(f'@"{sys.executable}" %*\n', encoding="utf-8")
|
||||
else:
|
||||
interpreter = interpreter_dir / "python"
|
||||
interpreter.write_text(
|
||||
f'#!/bin/sh\nexec "{sys.executable}" "$@"\n', encoding="utf-8"
|
||||
)
|
||||
interpreter.chmod(0o755)
|
||||
|
||||
(tmp_path / "probe.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable", str(interpreter)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.os", SimpleNamespace(name="nt")
|
||||
)
|
||||
|
||||
content = "---\nscripts:\n py: probe.py\n---\n{SCRIPT}\n"
|
||||
command = IntegrationBase.process_template(content, "agent", "py").splitlines()[-1]
|
||||
result = subprocess.run(
|
||||
[POWERSHELL_EXE, "-NoProfile", "-Command", command],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "ok"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PY_TEMPLATES)
|
||||
def test_sh_rendering_unchanged(name: str):
|
||||
# Negative: adding py: lines must not leak into sh rendering.
|
||||
@@ -102,6 +224,9 @@ def test_install_shared_infra_copies_python_scripts(tmp_path):
|
||||
console=Console(quiet=True),
|
||||
force=False,
|
||||
)
|
||||
dest = tmp_path / ".specify" / "scripts" / "python"
|
||||
assert (dest / "check_prerequisites.py").is_file()
|
||||
assert not (tmp_path / ".specify" / "scripts" / "powershell").exists()
|
||||
scripts_dir = tmp_path / ".specify" / "scripts"
|
||||
assert (scripts_dir / "python" / "check_prerequisites.py").is_file()
|
||||
shell_variant = "powershell" if os.name == "nt" else "bash"
|
||||
other_variant = "bash" if os.name == "nt" else "powershell"
|
||||
assert (scripts_dir / shell_variant).is_dir()
|
||||
assert not (scripts_dir / other_variant).exists()
|
||||
|
||||
837
tests/test_create_new_feature_python_parity.py
Normal file
837
tests/test_create_new_feature_python_parity.py
Normal file
@@ -0,0 +1,837 @@
|
||||
"""Parity tests for the Python create-new-feature port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.python import create_new_feature
|
||||
from scripts.python.common import persist_feature_json
|
||||
from tests.conftest import requires_bash
|
||||
from tests.parity_helpers import (
|
||||
HAS_POWERSHELL,
|
||||
bash_cmd,
|
||||
install_scripts,
|
||||
json_stdout,
|
||||
make_repo,
|
||||
normalize_repo_paths,
|
||||
normalize_script_names,
|
||||
ps_cmd,
|
||||
py_cmd,
|
||||
run,
|
||||
)
|
||||
|
||||
SCRIPT = "create-new-feature"
|
||||
TEMPLATE_BODY = "# Spec Template\n\nBody.\n"
|
||||
|
||||
|
||||
def _setup_repo(tmp_path: Path, name: str = "proj") -> Path:
|
||||
repo = make_repo(tmp_path, name)
|
||||
install_scripts(repo, SCRIPT)
|
||||
templates = repo / ".specify" / "templates"
|
||||
templates.mkdir(parents=True)
|
||||
(templates / "spec-template.md").write_text(TEMPLATE_BODY, encoding="utf-8")
|
||||
return repo
|
||||
|
||||
|
||||
def _normalized_error_text(stderr: str, repo: Path) -> str:
|
||||
stderr = re.sub(r"\x1b\[[0-9;]*m", "", stderr)
|
||||
stderr = re.sub(r"(?m)^\s*\|\s?", "", stderr)
|
||||
stderr = normalize_repo_paths(stderr, repo).replace("-Number", "--number")
|
||||
return " ".join(stderr.split())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> Path:
|
||||
return _setup_repo(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo_pair(tmp_path: Path) -> tuple[Path, Path]:
|
||||
return _setup_repo(tmp_path, "proj-a"), _setup_repo(tmp_path, "proj-b")
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"description",
|
||||
[
|
||||
"Add user authentication system",
|
||||
"I want to add the new API rate limiting feature for users",
|
||||
"Fix UI for DB sync",
|
||||
"a to the of",
|
||||
],
|
||||
ids=["plain", "stop_words", "acronyms", "all_stop_words_fallback"],
|
||||
)
|
||||
def test_python_branch_name_generation_matches_bash(
|
||||
repo: Path, description: str
|
||||
) -> None:
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert json_stdout(py) == json_stdout(bash)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
("--json", "--dry-run", "--number", "7", "add rate limiting"),
|
||||
("--json", "--dry-run", "--number", "010", "add rate limiting"),
|
||||
],
|
||||
ids=["explicit_number", "leading_zero_number"],
|
||||
)
|
||||
def test_python_number_flag_matches_bash(repo: Path, args: tuple[str, ...]) -> None:
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert json_stdout(py) == json_stdout(bash)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_sequential_numbering_matches_bash(repo: Path) -> None:
|
||||
for name in ("001-first", "0005-fourdigit", "20260101-120000-stamp", "12-short"):
|
||||
(repo / "specs" / name).mkdir(parents=True)
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", "add rate limiting"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", "add rate limiting"), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert json_stdout(py) == json_stdout(bash)
|
||||
assert json_stdout(py)["FEATURE_NUM"] == "006"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_all_variants_timestamp_mode_match_shape(repo: Path) -> None:
|
||||
args = ("--json", "--dry-run", "--timestamp", "--short-name", "user-auth", "x")
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
results = [bash, py]
|
||||
if HAS_POWERSHELL:
|
||||
results.append(
|
||||
run(
|
||||
ps_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"-Json",
|
||||
"-DryRun",
|
||||
"-Timestamp",
|
||||
"-ShortName",
|
||||
"user-auth",
|
||||
"x",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
)
|
||||
|
||||
assert all(result.returncode == 0 for result in results)
|
||||
# Timestamps may straddle a second boundary, so compare shape and suffix.
|
||||
for result in results:
|
||||
data = json_stdout(result)
|
||||
assert re.fullmatch(r"\d{8}-\d{6}-user-auth", data["BRANCH_NAME"])
|
||||
assert data["BRANCH_NAME"].startswith(data["FEATURE_NUM"])
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_timestamp_number_warning_matches(repo: Path) -> None:
|
||||
args = (
|
||||
"--json",
|
||||
"--dry-run",
|
||||
"--timestamp",
|
||||
"--number",
|
||||
"5",
|
||||
"--short-name",
|
||||
"ua",
|
||||
"x",
|
||||
)
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
ps = run(
|
||||
ps_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"-Json",
|
||||
"-DryRun",
|
||||
"-Timestamp",
|
||||
"-Number",
|
||||
"5",
|
||||
"-ShortName",
|
||||
"ua",
|
||||
"x",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert json_stdout(ps)
|
||||
assert (
|
||||
py.stderr
|
||||
== bash.stderr
|
||||
== ps.stderr.replace("-Number", "--number").replace(
|
||||
"-Timestamp", "--timestamp"
|
||||
)
|
||||
== "[specify] Warning: --number is ignored when --timestamp is used\n"
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_invalid_number_fails_cleanly(repo: Path) -> None:
|
||||
args = ("--json", "--dry-run", "--number", "abc", "add rate limiting")
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
ps = run(
|
||||
ps_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"-Json",
|
||||
"-DryRun",
|
||||
"-Number",
|
||||
"abc",
|
||||
"add rate limiting",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
expected = "Error: --number must be an unsigned integer, got 'abc'"
|
||||
for result in (bash, ps, py):
|
||||
assert expected in _normalized_error_text(result.stderr, repo)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_negative_number_fails_cleanly(repo: Path) -> None:
|
||||
args = ("--json", "--dry-run", "--number", "-1", "add rate limiting")
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
ps = run(
|
||||
ps_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"-Json",
|
||||
"-DryRun",
|
||||
"-Number",
|
||||
"-1",
|
||||
"add rate limiting",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
expected = "Error: --number must be an unsigned integer, got '-1'"
|
||||
for result in (bash, ps, py):
|
||||
assert expected in _normalized_error_text(result.stderr, repo)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize("digit_count", [244, 5000])
|
||||
def test_all_variants_oversized_number_fails_cleanly(
|
||||
repo: Path, digit_count: int
|
||||
) -> None:
|
||||
number = "9" * digit_count
|
||||
bash = run(
|
||||
bash_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"--json",
|
||||
"--dry-run",
|
||||
"--number",
|
||||
number,
|
||||
"add rate limiting",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
ps = run(
|
||||
ps_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"-Json",
|
||||
"-DryRun",
|
||||
"-Number",
|
||||
number,
|
||||
"add rate limiting",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
py = run(
|
||||
py_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"--json",
|
||||
"--dry-run",
|
||||
"--number",
|
||||
number,
|
||||
"add rate limiting",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
expected = (
|
||||
f"Error: --number must be between 0 and {2**63 - 1}, got '{number}'"
|
||||
)
|
||||
for result in (bash, ps, py):
|
||||
assert expected in _normalized_error_text(result.stderr, repo)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_branch_truncation_match(repo: Path) -> None:
|
||||
args = ("--json", "--dry-run", "--short-name", "a" * 300, "x")
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
ps = run(
|
||||
ps_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"-Json",
|
||||
"-DryRun",
|
||||
"-ShortName",
|
||||
"a" * 300,
|
||||
"x",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert bash.stderr == ps.stderr == py.stderr
|
||||
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
||||
assert len(json_stdout(py)["BRANCH_NAME"]) == 244
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_full_run_matches_bash(repo_pair: tuple[Path, Path]) -> None:
|
||||
repo_a, repo_b = repo_pair
|
||||
description = "Add user authentication system"
|
||||
|
||||
bash = run(bash_cmd(repo_a, SCRIPT, "--json", description), repo_a)
|
||||
py = run(py_cmd(repo_b, SCRIPT, "--json", description), repo_b)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert normalize_repo_paths(py.stdout, repo_b) == normalize_repo_paths(
|
||||
bash.stdout, repo_a
|
||||
)
|
||||
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
|
||||
bash.stderr, repo_a
|
||||
)
|
||||
|
||||
branch = json_stdout(py)["BRANCH_NAME"]
|
||||
for repo in repo_pair:
|
||||
spec = repo / "specs" / branch / "spec.md"
|
||||
assert spec.read_text(encoding="utf-8") == TEMPLATE_BODY
|
||||
assert (repo_b / ".specify" / "feature.json").read_bytes() == (
|
||||
repo_a / ".specify" / "feature.json"
|
||||
).read_bytes()
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_missing_template_warning_matches_bash(
|
||||
repo_pair: tuple[Path, Path],
|
||||
) -> None:
|
||||
repo_a, repo_b = repo_pair
|
||||
for repo in repo_pair:
|
||||
(repo / ".specify" / "templates" / "spec-template.md").unlink()
|
||||
|
||||
bash = run(bash_cmd(repo_a, SCRIPT, "--json", "add rate limiting"), repo_a)
|
||||
py = run(py_cmd(repo_b, SCRIPT, "--json", "add rate limiting"), repo_b)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
|
||||
bash.stderr, repo_a
|
||||
)
|
||||
branch = json_stdout(py)["BRANCH_NAME"]
|
||||
for repo in repo_pair:
|
||||
assert (repo / "specs" / branch / "spec.md").read_text(encoding="utf-8") == ""
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_existing_directory_error_matches_bash(
|
||||
repo_pair: tuple[Path, Path],
|
||||
) -> None:
|
||||
repo_a, repo_b = repo_pair
|
||||
description = "add rate limiting"
|
||||
|
||||
assert (
|
||||
run(
|
||||
bash_cmd(repo_a, SCRIPT, "--json", "--number", "1", description), repo_a
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
assert (
|
||||
run(
|
||||
py_cmd(repo_b, SCRIPT, "--json", "--number", "1", description), repo_b
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
|
||||
bash = run(bash_cmd(repo_a, SCRIPT, "--json", "--number", "1", description), repo_a)
|
||||
py = run(py_cmd(repo_b, SCRIPT, "--json", "--number", "1", description), repo_b)
|
||||
|
||||
assert py.returncode == bash.returncode == 1
|
||||
assert py.stdout == bash.stdout == ""
|
||||
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
|
||||
bash.stderr, repo_a
|
||||
)
|
||||
|
||||
bash_retry = run(
|
||||
bash_cmd(
|
||||
repo_a,
|
||||
SCRIPT,
|
||||
"--json",
|
||||
"--number",
|
||||
"1",
|
||||
"--allow-existing-branch",
|
||||
description,
|
||||
),
|
||||
repo_a,
|
||||
)
|
||||
py_retry = run(
|
||||
py_cmd(
|
||||
repo_b,
|
||||
SCRIPT,
|
||||
"--json",
|
||||
"--number",
|
||||
"1",
|
||||
"--allow-existing-branch",
|
||||
description,
|
||||
),
|
||||
repo_b,
|
||||
)
|
||||
assert py_retry.returncode == bash_retry.returncode == 0
|
||||
assert normalize_repo_paths(py_retry.stdout, repo_b) == normalize_repo_paths(
|
||||
bash_retry.stdout, repo_a
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
(),
|
||||
(" ",),
|
||||
("--short-name",),
|
||||
("--number",),
|
||||
],
|
||||
ids=["missing_description", "whitespace_description", "short_name_no_value", "number_no_value"],
|
||||
)
|
||||
def test_python_argument_errors_match_bash(repo: Path, args: tuple[str, ...]) -> None:
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 1
|
||||
assert py.stdout == bash.stdout == ""
|
||||
assert normalize_script_names(py.stderr, repo, SCRIPT) == normalize_script_names(
|
||||
bash.stderr, repo, SCRIPT
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_help_matches_bash(repo: Path) -> None:
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--help"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--help"), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert normalize_script_names(py.stdout, repo, SCRIPT) == normalize_script_names(
|
||||
bash.stdout, repo, SCRIPT
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_persists_relative_feature_json(repo: Path) -> None:
|
||||
py = run(py_cmd(repo, SCRIPT, "--json", "add rate limiting"), repo)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
branch = json_stdout(py)["BRANCH_NAME"]
|
||||
feature_json = (repo / ".specify" / "feature.json").read_text(encoding="utf-8")
|
||||
assert feature_json == f'{{"feature_directory":"specs/{branch}"}}\n'
|
||||
|
||||
|
||||
def test_persist_feature_json_avoids_platform_newline_translation(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
def windows_write_text(path: Path, data: str, **kwargs) -> int:
|
||||
encoding = kwargs.get("encoding") or "utf-8"
|
||||
return path.write_bytes(data.replace("\n", "\r\n").encode(encoding))
|
||||
|
||||
monkeypatch.setattr(Path, "write_text", windows_write_text)
|
||||
|
||||
persist_feature_json(tmp_path, "specs/001-test")
|
||||
|
||||
assert (tmp_path / ".specify" / "feature.json").read_bytes() == (
|
||||
b'{"feature_directory":"specs/001-test"}\n'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
("py_args", "ps_args"),
|
||||
[
|
||||
(
|
||||
("--json", "--dry-run", "Add user authentication system"),
|
||||
("-Json", "-DryRun", "Add user authentication system"),
|
||||
),
|
||||
(
|
||||
("--json", "--dry-run", "--short-name", "My Fancy Name", "x"),
|
||||
("-Json", "-DryRun", "-ShortName", "My Fancy Name", "x"),
|
||||
),
|
||||
(
|
||||
("--json", "--dry-run", "--number", "7", "add rate limiting"),
|
||||
("-Json", "-DryRun", "-Number", "7", "add rate limiting"),
|
||||
),
|
||||
],
|
||||
ids=["plain", "short_name", "number"],
|
||||
)
|
||||
def test_python_json_output_matches_powershell(
|
||||
repo: Path, py_args: tuple[str, ...], ps_args: tuple[str, ...]
|
||||
) -> None:
|
||||
ps = run(ps_cmd(repo, SCRIPT, *ps_args), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, *py_args), repo)
|
||||
|
||||
assert py.returncode == ps.returncode == 0
|
||||
assert json_stdout(py) == json_stdout(ps)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize("number", ["-1", "+1"], ids=["negative", "positive_sign"])
|
||||
def test_all_variants_reject_signed_number(repo: Path, number: str) -> None:
|
||||
bash = run(
|
||||
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
|
||||
repo,
|
||||
)
|
||||
ps = run(
|
||||
ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "-Number", number, "x"),
|
||||
repo,
|
||||
)
|
||||
py = run(
|
||||
py_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
|
||||
repo,
|
||||
)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
expected = f"Error: --number must be an unsigned integer, got '{number}'"
|
||||
for result in (bash, ps, py):
|
||||
assert expected in _normalized_error_text(result.stderr, repo)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize("timestamp", [False, True], ids=["numbered", "timestamp"])
|
||||
def test_all_variants_treat_empty_number_as_omitted(
|
||||
repo: Path, timestamp: bool
|
||||
) -> None:
|
||||
bash_args = ["--json", "--dry-run", "--number", ""]
|
||||
ps_args = ["-Json", "-DryRun", "-Number", ""]
|
||||
py_args = ["--json", "--dry-run", "--number", ""]
|
||||
if timestamp:
|
||||
bash_args.append("--timestamp")
|
||||
ps_args.append("-Timestamp")
|
||||
py_args.append("--timestamp")
|
||||
bash_args.append("x")
|
||||
ps_args.append("x")
|
||||
py_args.append("x")
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, *bash_args), repo)
|
||||
ps = run(ps_cmd(repo, SCRIPT, *ps_args), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, *py_args), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert bash.stderr == ps.stderr == py.stderr == ""
|
||||
if not timestamp:
|
||||
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
("number", "returncode"),
|
||||
[
|
||||
(str(2**63 - 1), 0),
|
||||
(str(2**63), 1),
|
||||
],
|
||||
ids=["int64_max", "int64_overflow"],
|
||||
)
|
||||
def test_all_variants_share_int64_number_range(
|
||||
repo: Path, number: str, returncode: int
|
||||
) -> None:
|
||||
bash = run(
|
||||
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
|
||||
repo,
|
||||
)
|
||||
ps = run(
|
||||
ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "-Number", number, "x"),
|
||||
repo,
|
||||
)
|
||||
py = run(
|
||||
py_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
|
||||
repo,
|
||||
)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == returncode
|
||||
if returncode == 0:
|
||||
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
||||
else:
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
expected = f"Error: --number must be between 0 and {2**63 - 1}, got '{number}'"
|
||||
for result in (bash, ps, py):
|
||||
assert expected in _normalized_error_text(result.stderr, repo)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_reject_exhausted_auto_number_range(repo: Path) -> None:
|
||||
(repo / "specs" / f"{2**63 - 1}-existing").mkdir(parents=True)
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "x"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
expected = f"Error: feature number must be between 0 and {2**63 - 1}, got '{2**63}'"
|
||||
for result in (bash, ps, py):
|
||||
assert expected in _normalized_error_text(result.stderr, repo)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize("prefix", [2**63, 2**64 + 5])
|
||||
def test_all_variants_ignore_out_of_range_existing_prefix(
|
||||
repo: Path, prefix: int
|
||||
) -> None:
|
||||
(repo / "specs" / f"{prefix}-existing").mkdir(parents=True)
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "x"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
||||
assert json_stdout(py)["FEATURE_NUM"] == "001"
|
||||
|
||||
|
||||
def test_python_ignores_unconvertibly_large_existing_prefix() -> None:
|
||||
class Entry:
|
||||
name = f"{'9' * 5000}-existing"
|
||||
|
||||
@staticmethod
|
||||
def is_dir() -> bool:
|
||||
return True
|
||||
|
||||
class SpecsDir:
|
||||
@staticmethod
|
||||
def is_dir() -> bool:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def iterdir() -> list[Entry]:
|
||||
return [Entry()]
|
||||
|
||||
assert create_new_feature._get_highest_from_specs(SpecsDir()) == 0
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_text_mode_match(repo: Path) -> None:
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--dry-run", "--number", "7", "x"), repo)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-DryRun", "-Number", "7", "x"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--dry-run", "--number", "7", "x"), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert bash.stderr == ps.stderr == py.stderr == ""
|
||||
assert (
|
||||
normalize_repo_paths(bash.stdout, repo)
|
||||
== normalize_repo_paths(ps.stdout, repo)
|
||||
== normalize_repo_paths(py.stdout, repo)
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_non_dry_text_mode_match(tmp_path: Path) -> None:
|
||||
bash_repo = _setup_repo(tmp_path, "bash")
|
||||
ps_repo = _setup_repo(tmp_path, "powershell")
|
||||
py_repo = _setup_repo(tmp_path, "python")
|
||||
|
||||
bash = run(
|
||||
bash_cmd(bash_repo, SCRIPT, "--number", "7", "x"), bash_repo
|
||||
)
|
||||
ps = run(
|
||||
ps_cmd(ps_repo, SCRIPT, "-Number", "7", "x"), ps_repo
|
||||
)
|
||||
py = run(py_cmd(py_repo, SCRIPT, "--number", "7", "x"), py_repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert (
|
||||
normalize_repo_paths(bash.stdout, bash_repo)
|
||||
== normalize_repo_paths(py.stdout, py_repo)
|
||||
)
|
||||
assert (
|
||||
normalize_repo_paths(bash.stderr, bash_repo)
|
||||
== normalize_repo_paths(py.stderr, py_repo)
|
||||
)
|
||||
ps_stdout = normalize_repo_paths(ps.stdout, ps_repo)
|
||||
ps_stderr = normalize_repo_paths(ps.stderr, ps_repo)
|
||||
assert "$env:SPECIFY_FEATURE = '007-x'" in ps_stdout
|
||||
assert (
|
||||
"$env:SPECIFY_FEATURE_DIRECTORY = '<REPO>/specs/007-x'" in ps_stdout
|
||||
)
|
||||
assert "$env:SPECIFY_FEATURE = '007-x'" in ps_stderr
|
||||
assert (
|
||||
"$env:SPECIFY_FEATURE_DIRECTORY = '<REPO>/specs/007-x'" in ps_stderr
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_persist_hints_match_bash_for_spaced_repo_path(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Paths with spaces must be quoted identically (shlex.quote format) so
|
||||
the side-by-side text/stderr comparison holds."""
|
||||
bash_repo = _setup_repo(tmp_path, "my proj a")
|
||||
py_repo = _setup_repo(tmp_path, "my proj b")
|
||||
|
||||
bash = run(bash_cmd(bash_repo, SCRIPT, "--number", "7", "x"), bash_repo)
|
||||
py = run(py_cmd(py_repo, SCRIPT, "--number", "7", "x"), py_repo)
|
||||
|
||||
assert bash.returncode == py.returncode == 0, bash.stderr + py.stderr
|
||||
assert normalize_repo_paths(bash.stdout, bash_repo) == normalize_repo_paths(
|
||||
py.stdout, py_repo
|
||||
)
|
||||
assert normalize_repo_paths(bash.stderr, bash_repo) == normalize_repo_paths(
|
||||
py.stderr, py_repo
|
||||
)
|
||||
assert "export SPECIFY_FEATURE_DIRECTORY='<REPO>/specs/007-x'" in (
|
||||
normalize_repo_paths(py.stderr, py_repo)
|
||||
)
|
||||
|
||||
|
||||
def test_python_powershell_persistence_assignments_escape_quotes() -> None:
|
||||
assert create_new_feature._persistence_assignments(
|
||||
"007-x", r"C:\repo\O'Brien", powershell=True
|
||||
) == (
|
||||
"$env:SPECIFY_FEATURE = '007-x'",
|
||||
"$env:SPECIFY_FEATURE_DIRECTORY = 'C:\\repo\\O''Brien'",
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_persist_symlinked_specs_path_lexically(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repos = [
|
||||
_setup_repo(tmp_path, "bash"),
|
||||
_setup_repo(tmp_path, "powershell"),
|
||||
_setup_repo(tmp_path, "python"),
|
||||
]
|
||||
for current in repos:
|
||||
specs_target = tmp_path / f"{current.name}-specs"
|
||||
specs_target.mkdir()
|
||||
try:
|
||||
(current / "specs").symlink_to(
|
||||
specs_target, target_is_directory=True
|
||||
)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("Symlinks are not available in this environment")
|
||||
|
||||
bash = run(
|
||||
bash_cmd(repos[0], SCRIPT, "--json", "--number", "7", "x"),
|
||||
repos[0],
|
||||
)
|
||||
ps = run(
|
||||
ps_cmd(repos[1], SCRIPT, "-Json", "-Number", "7", "x"),
|
||||
repos[1],
|
||||
)
|
||||
py = run(
|
||||
py_cmd(repos[2], SCRIPT, "--json", "--number", "7", "x"),
|
||||
repos[2],
|
||||
)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
expected = '{"feature_directory":"specs/007-x"}'
|
||||
for current in repos:
|
||||
assert (
|
||||
current / ".specify" / "feature.json"
|
||||
).read_text(encoding="utf-8").strip() == expected
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_allow_existing_branch(repo: Path) -> None:
|
||||
feature_dir = repo / "specs" / "001-x"
|
||||
feature_dir.mkdir(parents=True)
|
||||
spec_file = feature_dir / "spec.md"
|
||||
spec_file.write_text("existing\n", encoding="utf-8")
|
||||
|
||||
bash = run(
|
||||
bash_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"--json",
|
||||
"--number",
|
||||
"1",
|
||||
"--allow-existing-branch",
|
||||
"x",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
ps = run(
|
||||
ps_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"-Json",
|
||||
"-Number",
|
||||
"1",
|
||||
"-AllowExistingBranch",
|
||||
"x",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
py = run(
|
||||
py_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"--json",
|
||||
"--number",
|
||||
"1",
|
||||
"--allow-existing-branch",
|
||||
"x",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
||||
assert spec_file.read_text(encoding="utf-8") == "existing\n"
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_existing_directory_failure_diagnostics(repo: Path) -> None:
|
||||
(repo / "specs" / "001-x").mkdir(parents=True)
|
||||
expected = (
|
||||
"Error: Feature directory '<REPO>/specs/001-x' already exists. "
|
||||
"Please use a different feature name or specify a different number "
|
||||
"with --number."
|
||||
)
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json", "--number", "1", "x"), repo)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json", "-Number", "1", "x"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json", "--number", "1", "x"), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
for result in (bash, ps, py):
|
||||
assert expected in _normalized_error_text(result.stderr, repo)
|
||||
@@ -978,6 +978,7 @@ class TestExtensionSkillRegistration:
|
||||
("codex", "$speckit-plan"),
|
||||
("kimi", "/skill:speckit-plan"),
|
||||
("zcode", "$speckit-plan"),
|
||||
("bob", "/speckit-plan"),
|
||||
],
|
||||
)
|
||||
def test_skill_registration_resolves_command_ref_tokens(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -144,7 +144,7 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
@contextmanager
|
||||
def failing_open(url, timeout=None, extra_headers=None):
|
||||
raise urllib.error.URLError("network error")
|
||||
yield # noqa: unreachable
|
||||
yield # pragma: no cover
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://github.com/org/repo/releases/download/v1/pack.zip",
|
||||
|
||||
@@ -39,3 +39,21 @@ def test_pinned_action_ref_accepts_uppercase_hex_sha():
|
||||
assert PINNED_SHA_RE.search(
|
||||
"actions/example@0123456789ABCDEF0123456789ABCDEF01234567"
|
||||
)
|
||||
|
||||
|
||||
def test_community_bundle_submission_automation_is_wired():
|
||||
source = WORKFLOWS_DIR / "add-community-bundle.md"
|
||||
compiled = WORKFLOWS_DIR / "add-community-bundle.lock.yml"
|
||||
assignment = WORKFLOWS_DIR / "catalog-assign.yml"
|
||||
|
||||
assert source.is_file()
|
||||
assert compiled.is_file()
|
||||
source_text = source.read_text(encoding="utf-8")
|
||||
assignment_text = assignment.read_text(encoding="utf-8")
|
||||
|
||||
assert "names: [bundle-submission]" in source_text
|
||||
assert "bundles/catalog.community.json" in source_text
|
||||
assert "docs/community/bundles.md" in source_text
|
||||
assert "verified: false" in source_text
|
||||
assert "allowed-files:" in source_text
|
||||
assert "bundle-submission" in assignment_text
|
||||
|
||||
@@ -1766,6 +1766,103 @@ class TestPresetCatalog:
|
||||
|
||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
|
||||
def test_fetch_single_catalog_revalidates_redirected_url(self, project_dir):
|
||||
"""An HTTPS catalog URL that redirects to http:// must be rejected AFTER
|
||||
the redirect. _open_url follows redirects (auth stripped on downgrade),
|
||||
so without re-validating response.geturl() the http payload would still
|
||||
be fetched and trusted — and it supplies each preset's download_url +
|
||||
sha256, defeating verify_archive_sha256. Parity with the
|
||||
integrations/workflows catalog fetchers."""
|
||||
catalog = PresetCatalog(project_dir)
|
||||
|
||||
class _Resp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps({"schema_version": "1.0", "presets": {}}).encode()
|
||||
|
||||
def geturl(self):
|
||||
return "http://evil.test/catalog.json" # downgraded via redirect
|
||||
|
||||
catalog._open_url = lambda url, timeout=None, redirect_validator=None: _Resp()
|
||||
|
||||
entry = PresetCatalogEntry(
|
||||
url="https://good.example/catalog.json",
|
||||
name="c",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
with pytest.raises(PresetValidationError, match="HTTPS"):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
def test_fetch_single_catalog_validates_every_redirect_hop(self, project_dir):
|
||||
"""A redirect_validator is passed to _open_url and rejects a non-HTTPS
|
||||
INTERMEDIATE hop — closing the https -> http -> attacker-https chain that
|
||||
a terminal-URL-only check would miss."""
|
||||
catalog = PresetCatalog(project_dir)
|
||||
captured = {}
|
||||
|
||||
def fake_open(url, timeout=None, redirect_validator=None):
|
||||
captured["rv"] = redirect_validator
|
||||
# Simulate the hop urllib validates before following the redirect.
|
||||
redirect_validator("https://good.example/catalog.json", "http://evil.test/hop")
|
||||
raise AssertionError("redirect_validator should have raised")
|
||||
|
||||
catalog._open_url = fake_open
|
||||
entry = PresetCatalogEntry(
|
||||
url="https://good.example/catalog.json",
|
||||
name="c",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
with pytest.raises(PresetValidationError, match="HTTPS"):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
assert captured["rv"] is not None
|
||||
|
||||
def test_fetch_catalog_legacy_revalidates_redirected_url(self, project_dir):
|
||||
"""The legacy single-catalog fetch_catalog() path also rejects an
|
||||
HTTPS -> http redirected payload (final geturl() check), matching
|
||||
_fetch_single_catalog — it previously parsed the body with no check."""
|
||||
catalog = PresetCatalog(project_dir)
|
||||
|
||||
class _Resp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps({"schema_version": "1.0", "presets": {}}).encode()
|
||||
|
||||
def geturl(self):
|
||||
return "http://evil.test/catalog.json"
|
||||
|
||||
catalog._open_url = lambda url, timeout=None, redirect_validator=None: _Resp()
|
||||
with pytest.raises(PresetError, match="HTTPS"):
|
||||
catalog.fetch_catalog(force_refresh=True)
|
||||
|
||||
def test_fetch_catalog_legacy_validates_every_redirect_hop(self, project_dir):
|
||||
"""The legacy fetch_catalog() path also validates every INTERMEDIATE hop
|
||||
(not just the terminal URL): it must supply a redirect_validator that
|
||||
rejects an insecure hop, so an https -> http -> https chain is caught."""
|
||||
catalog = PresetCatalog(project_dir)
|
||||
captured = {}
|
||||
|
||||
def fake_open(url, timeout=None, redirect_validator=None):
|
||||
captured["rv"] = redirect_validator
|
||||
redirect_validator(url, "http://evil.test/hop")
|
||||
raise AssertionError("redirect_validator should have raised")
|
||||
|
||||
catalog._open_url = fake_open
|
||||
with pytest.raises(PresetError, match="HTTPS"):
|
||||
catalog.fetch_catalog(force_refresh=True)
|
||||
assert captured["rv"] is not None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
@@ -1799,6 +1896,9 @@ class TestPresetCatalog:
|
||||
mock_response.read.return_value = json.dumps(payload).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
# A real urllib response reports the final URL (== request URL with no
|
||||
# redirect); the fetcher re-validates it after redirects.
|
||||
mock_response.geturl.return_value = "https://example.com/catalog.json"
|
||||
|
||||
entry = PresetCatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
@@ -1868,6 +1968,7 @@ class TestPresetCatalog:
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = catalog.DEFAULT_CATALOG_URL
|
||||
|
||||
entry = PresetCatalogEntry(
|
||||
url=catalog.DEFAULT_CATALOG_URL,
|
||||
@@ -1915,6 +2016,7 @@ class TestPresetCatalog:
|
||||
mock_response.read.return_value = json.dumps(payload).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = "https://example.com/catalog.json"
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
with pytest.raises(PresetError, match="Invalid preset catalog format"):
|
||||
@@ -1956,6 +2058,7 @@ class TestPresetCatalog:
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = "https://example.com/catalog.json"
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
result = catalog.fetch_catalog(force_refresh=False)
|
||||
@@ -1994,6 +2097,7 @@ class TestPresetCatalog:
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = "https://example.com/catalog.json"
|
||||
|
||||
with patch.object(catalog, "_open_url", return_value=mock_response):
|
||||
result = catalog.fetch_catalog(force_refresh=False)
|
||||
@@ -2064,6 +2168,7 @@ class TestPresetCatalog:
|
||||
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = "https://example.com/catalog.json"
|
||||
|
||||
# Record every ``write_text`` call's encoding kwarg so the
|
||||
# assertion observes the production writer's argument directly.
|
||||
@@ -2113,6 +2218,7 @@ class TestPresetCatalog:
|
||||
mock_response.read.return_value = json.dumps(valid).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = catalog.DEFAULT_CATALOG_URL
|
||||
|
||||
# Simulate an unwritable cache dir: every write_text under the
|
||||
# cache directory raises PermissionError (an OSError subclass).
|
||||
@@ -2165,6 +2271,7 @@ class TestPresetCatalog:
|
||||
mock_response.read.return_value = json.dumps(payload).encode()
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = "https://example.com/catalog.json"
|
||||
|
||||
entry = PresetCatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
@@ -2303,6 +2410,28 @@ class TestPresetCatalog:
|
||||
with pytest.raises(PresetError, match="[Ii]ntegrity"):
|
||||
catalog.download_pack("test-pack", target_dir=project_dir)
|
||||
|
||||
def test_download_pack_malformed_url_raises_preset_error(self, project_dir):
|
||||
"""A catalog ``download_url`` with a malformed authority (e.g. an
|
||||
unterminated IPv6 bracket) surfaces a clean ``PresetError`` rather than
|
||||
leaking a raw ``ValueError`` from ``urlparse``/``.hostname`` past the
|
||||
command handler (which only catches ``PresetError``). Mirrors the
|
||||
extensions coverage.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
for bad_url in ("https://[::1", "https://[not-an-ip]/x"):
|
||||
pack_info = {
|
||||
"id": "test-pack",
|
||||
"name": "Test Pack",
|
||||
"version": "1.0.0",
|
||||
"download_url": bad_url,
|
||||
"_install_allowed": True,
|
||||
}
|
||||
with patch.object(catalog, "get_pack_info", return_value=pack_info):
|
||||
with pytest.raises(PresetError, match="malformed"):
|
||||
catalog.download_pack("test-pack", target_dir=project_dir)
|
||||
|
||||
def test_download_pack_without_sha256_skips_verification(self, project_dir):
|
||||
"""A catalog entry with no ``sha256`` keeps working: verification is
|
||||
opt-in, so the backwards-compatible path (``pack_info.get("sha256")``
|
||||
@@ -2671,6 +2800,24 @@ class TestPresetCatalogMultiCatalog:
|
||||
with pytest.raises(PresetValidationError, match="Invalid priority|expected integer"):
|
||||
catalog._load_catalog_config(config_path)
|
||||
|
||||
def test_load_catalog_config_rejects_infinite_priority(self, project_dir):
|
||||
"""A ``priority: .inf`` yields a clean validation error, not an uncaught
|
||||
OverflowError from int(float('inf'))."""
|
||||
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"catalogs": [
|
||||
{
|
||||
"name": "inf-priority",
|
||||
"url": "https://example.com/catalog.json",
|
||||
"priority": float("inf"),
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
with pytest.raises(PresetValidationError, match="Invalid priority|expected integer"):
|
||||
catalog._load_catalog_config(config_path)
|
||||
|
||||
def test_load_catalog_config_install_allowed_string(self, project_dir):
|
||||
"""Test that install_allowed accepts string values."""
|
||||
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
||||
@@ -5430,6 +5577,82 @@ class TestBundledPresetLocator:
|
||||
assert "Invalid URL" in output
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_bracketed_host_download_url_exits_cleanly(self, project_dir):
|
||||
"""A catalog download_url with a bracketed non-IP host must render cleanly.
|
||||
|
||||
``download_pack`` raises ``PresetError`` whose message embeds the raw URL
|
||||
(e.g. ``https://[not-an-ip]/x``). The ``preset_add`` handler must escape
|
||||
that message before printing so Rich does not interpret ``[not-an-ip]``
|
||||
as a markup tag and crash while rendering the error.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
bad_url = "https://[not-an-ip]/x"
|
||||
catalog_data = {
|
||||
"test-pack": {
|
||||
"name": "Test Pack",
|
||||
"version": "1.0.0",
|
||||
"download_url": bad_url,
|
||||
}
|
||||
}
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch.object(PresetCatalog, "_get_merged_packs", return_value=catalog_data):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["preset", "add", "test-pack"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
output = strip_ansi(result.output)
|
||||
assert "Error:" in output
|
||||
# The malformed URL surfaces verbatim rather than crashing the renderer.
|
||||
assert bad_url in output
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exc_type", "label"),
|
||||
[
|
||||
(PresetCompatibilityError, "Compatibility Error"),
|
||||
(PresetValidationError, "Validation Error"),
|
||||
(PresetError, "Error"),
|
||||
],
|
||||
)
|
||||
def test_preset_add_exception_handlers_escape_markup(self, project_dir, exc_type, label):
|
||||
"""Preset install exceptions can include catalog-controlled values.
|
||||
|
||||
The message must be escaped so Rich does not treat bracketed content as
|
||||
markup and raise while rendering the error.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
dev_dir = project_dir / "dev-pack"
|
||||
dev_dir.mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch.object(
|
||||
PresetManager,
|
||||
"install_from_directory",
|
||||
side_effect=exc_type("bad [red]preset[/red]"),
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["preset", "add", "--dev", str(dev_dir)],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert f"{label}:" in result.output
|
||||
assert "bad [red]preset[/red]" in result.output
|
||||
|
||||
def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys):
|
||||
"""Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs."""
|
||||
import typer
|
||||
|
||||
316
tests/test_setup_plan_python_parity.py
Normal file
316
tests/test_setup_plan_python_parity.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""Parity tests for the Python setup-plan port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
from tests.parity_helpers import (
|
||||
HAS_POWERSHELL,
|
||||
POWERSHELL_EXE,
|
||||
bash_cmd,
|
||||
clean_env,
|
||||
install_scripts,
|
||||
json_stdout,
|
||||
make_repo,
|
||||
normalize_repo_paths,
|
||||
ps_cmd,
|
||||
py_cmd,
|
||||
run,
|
||||
write_feature_json,
|
||||
)
|
||||
|
||||
SCRIPT = "setup-plan"
|
||||
TEMPLATE_BODY = "# Plan Template\n\nBody.\n"
|
||||
|
||||
|
||||
def _setup_repo(tmp_path: Path, name: str = "proj", template: bool = True) -> Path:
|
||||
repo = make_repo(tmp_path, name)
|
||||
install_scripts(repo, SCRIPT)
|
||||
write_feature_json(repo)
|
||||
(repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
if template:
|
||||
templates = repo / ".specify" / "templates"
|
||||
templates.mkdir(parents=True)
|
||||
(templates / "plan-template.md").write_text(TEMPLATE_BODY, encoding="utf-8")
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> Path:
|
||||
return _setup_repo(tmp_path)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_fresh_copy_matches_bash(tmp_path: Path) -> None:
|
||||
repo_a = _setup_repo(tmp_path, "proj-a")
|
||||
repo_b = _setup_repo(tmp_path, "proj-b")
|
||||
|
||||
bash = run(bash_cmd(repo_a, SCRIPT, "--json"), repo_a)
|
||||
py = run(py_cmd(repo_b, SCRIPT, "--json"), repo_b)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert normalize_repo_paths(py.stdout, repo_b) == normalize_repo_paths(
|
||||
bash.stdout, repo_a
|
||||
)
|
||||
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
|
||||
bash.stderr, repo_a
|
||||
)
|
||||
for repo in (repo_a, repo_b):
|
||||
plan = repo / "specs" / "001-my-feature" / "plan.md"
|
||||
assert plan.read_text(encoding="utf-8") == TEMPLATE_BODY
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize("args", [("--json",), ()], ids=["json", "text"])
|
||||
def test_python_existing_plan_matches_bash(repo: Path, args: tuple[str, ...]) -> None:
|
||||
plan = repo / "specs" / "001-my-feature" / "plan.md"
|
||||
plan.write_text("# existing\n", encoding="utf-8")
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, *args), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stdout == bash.stdout
|
||||
assert py.stderr == bash.stderr
|
||||
assert plan.read_text(encoding="utf-8") == "# existing\n"
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_ignore_extra_arguments(tmp_path: Path) -> None:
|
||||
repos = [
|
||||
_setup_repo(tmp_path, "bash"),
|
||||
_setup_repo(tmp_path, "powershell"),
|
||||
_setup_repo(tmp_path, "python"),
|
||||
]
|
||||
|
||||
bash = run(bash_cmd(repos[0], SCRIPT, "--json", "--bogus"), repos[0])
|
||||
ps = run(ps_cmd(repos[1], SCRIPT, "-Json", "--bogus"), repos[1])
|
||||
py = run(py_cmd(repos[2], SCRIPT, "--json", "--bogus"), repos[2])
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert normalize_repo_paths(bash.stdout, repos[0]) == normalize_repo_paths(
|
||||
ps.stdout, repos[1]
|
||||
) == normalize_repo_paths(py.stdout, repos[2])
|
||||
assert normalize_repo_paths(bash.stderr, repos[0]) == normalize_repo_paths(
|
||||
ps.stderr, repos[1]
|
||||
) == normalize_repo_paths(py.stderr, repos[2])
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_missing_template_matches_bash(tmp_path: Path) -> None:
|
||||
repo_a = _setup_repo(tmp_path, "proj-a", template=False)
|
||||
repo_b = _setup_repo(tmp_path, "proj-b", template=False)
|
||||
|
||||
bash = run(bash_cmd(repo_a, SCRIPT, "--json"), repo_a)
|
||||
py = run(py_cmd(repo_b, SCRIPT, "--json"), repo_b)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
|
||||
bash.stderr, repo_a
|
||||
)
|
||||
for repo in (repo_a, repo_b):
|
||||
plan = repo / "specs" / "001-my-feature" / "plan.md"
|
||||
assert plan.read_text(encoding="utf-8") == ""
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"registry",
|
||||
[
|
||||
'{"presets": {"alpha": {"priority": "high"}, "beta": {"priority": 1}}}',
|
||||
'{"presets": {"alpha": {"priority": 2}, "beta": {"priority": 1}, "gamma": {"priority": null}}}',
|
||||
"[]",
|
||||
'{"presets":[]}',
|
||||
'{"presets":null}',
|
||||
],
|
||||
ids=[
|
||||
"mixed_priorities",
|
||||
"null_priority",
|
||||
"list_root",
|
||||
"list_presets",
|
||||
"null_presets",
|
||||
],
|
||||
)
|
||||
def test_all_variants_broken_registry_falls_back_to_dir_scan(
|
||||
tmp_path: Path, registry: str
|
||||
) -> None:
|
||||
"""Malformed registries fall back to the alphabetical directory scan."""
|
||||
repos = [
|
||||
_setup_repo(tmp_path, "bash", template=False),
|
||||
_setup_repo(tmp_path, "powershell", template=False),
|
||||
_setup_repo(tmp_path, "python", template=False),
|
||||
]
|
||||
for repo in repos:
|
||||
presets = repo / ".specify" / "presets"
|
||||
for name, body in (
|
||||
(".hidden", "# hidden\n"),
|
||||
("beta", "# beta plan\n"),
|
||||
("alpha", "# alpha plan\n"),
|
||||
):
|
||||
(presets / name / "templates").mkdir(parents=True)
|
||||
(presets / name / "templates" / "plan-template.md").write_text(
|
||||
body, encoding="utf-8"
|
||||
)
|
||||
(presets / ".registry").write_text(
|
||||
registry, encoding="utf-8"
|
||||
)
|
||||
|
||||
bash = run(bash_cmd(repos[0], SCRIPT, "--json"), repos[0])
|
||||
py = run(py_cmd(repos[2], SCRIPT, "--json"), repos[2])
|
||||
results = [(bash, repos[0]), (py, repos[2])]
|
||||
if HAS_POWERSHELL:
|
||||
results.insert(
|
||||
1,
|
||||
(run(ps_cmd(repos[1], SCRIPT, "-Json"), repos[1]), repos[1]),
|
||||
)
|
||||
|
||||
assert all(result.returncode == 0 for result, _ in results)
|
||||
assert len(
|
||||
{
|
||||
normalize_repo_paths(result.stdout, repo)
|
||||
for result, repo in results
|
||||
}
|
||||
) == 1
|
||||
assert len(
|
||||
{
|
||||
normalize_repo_paths(result.stderr, repo)
|
||||
for result, repo in results
|
||||
}
|
||||
) == 1
|
||||
for _, repo in results:
|
||||
plan = repo / "specs" / "001-my-feature" / "plan.md"
|
||||
assert plan.read_text(encoding="utf-8") == "# alpha plan\n"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_powershell_broken_registry_fallback_sorts_directories(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repo = _setup_repo(tmp_path, "powershell", template=False)
|
||||
presets = repo / ".specify" / "presets"
|
||||
for name in ("alpha", "beta"):
|
||||
templates = presets / name / "templates"
|
||||
templates.mkdir(parents=True)
|
||||
(templates / "plan-template.md").write_text(
|
||||
f"# {name} plan\n", encoding="utf-8"
|
||||
)
|
||||
(presets / ".registry").write_text("{broken", encoding="utf-8")
|
||||
|
||||
common = repo / ".specify" / "scripts" / "powershell" / "common.ps1"
|
||||
common_ps = str(common).replace("'", "''")
|
||||
alpha_ps = str(presets / "alpha").replace("'", "''")
|
||||
beta_ps = str(presets / "beta").replace("'", "''")
|
||||
repo_ps = str(repo).replace("'", "''")
|
||||
command = f"""
|
||||
. '{common_ps}'
|
||||
function Get-ChildItem {{
|
||||
@(
|
||||
[PSCustomObject]@{{ Name = 'beta'; FullName = '{beta_ps}' }}
|
||||
[PSCustomObject]@{{ Name = 'alpha'; FullName = '{alpha_ps}' }}
|
||||
)
|
||||
}}
|
||||
Resolve-Template -TemplateName 'plan-template' -RepoRoot '{repo_ps}'
|
||||
"""
|
||||
result = run(
|
||||
[POWERSHELL_EXE, "-NoProfile", "-Command", command],
|
||||
repo,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stderr == ""
|
||||
assert Path(result.stdout.strip()).read_text(encoding="utf-8") == (
|
||||
"# alpha plan\n"
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
"context", ["missing", "invalid_json", "invalid_utf8", "invalid_init_dir"]
|
||||
)
|
||||
def test_all_variants_feature_context_error_matches(
|
||||
tmp_path: Path, context: str
|
||||
) -> None:
|
||||
repo = make_repo(tmp_path)
|
||||
install_scripts(repo, SCRIPT)
|
||||
env = None
|
||||
if context == "invalid_json":
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
"{not json", encoding="utf-8"
|
||||
)
|
||||
elif context == "invalid_utf8":
|
||||
(repo / ".specify" / "feature.json").write_bytes(b"\xff")
|
||||
elif context == "invalid_init_dir":
|
||||
env = clean_env()
|
||||
env["SPECIFY_INIT_DIR"] = str(tmp_path / "missing")
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo, env)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json"), repo, env)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo, env)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
assert bash.stderr == ps.stderr == py.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
"registry",
|
||||
[
|
||||
'{"presets":{"alpha":{"enabled":false,"priority":1}}}',
|
||||
'{"presets":{"alpha":"invalid"}}',
|
||||
],
|
||||
ids=["disabled", "invalid_metadata"],
|
||||
)
|
||||
def test_all_variants_ignore_inactive_preset_template(
|
||||
tmp_path: Path, registry: str
|
||||
) -> None:
|
||||
repos = [
|
||||
_setup_repo(tmp_path, "bash"),
|
||||
_setup_repo(tmp_path, "powershell"),
|
||||
_setup_repo(tmp_path, "python"),
|
||||
]
|
||||
for current in repos:
|
||||
preset_templates = (
|
||||
current / ".specify" / "presets" / "alpha" / "templates"
|
||||
)
|
||||
preset_templates.mkdir(parents=True)
|
||||
(preset_templates / "plan-template.md").write_text(
|
||||
"# Disabled preset\n", encoding="utf-8"
|
||||
)
|
||||
(current / ".specify" / "presets" / ".registry").write_text(
|
||||
registry, encoding="utf-8"
|
||||
)
|
||||
|
||||
bash = run(bash_cmd(repos[0], SCRIPT, "--json"), repos[0])
|
||||
ps = run(ps_cmd(repos[1], SCRIPT, "-Json"), repos[1])
|
||||
py = run(py_cmd(repos[2], SCRIPT, "--json"), repos[2])
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert normalize_repo_paths(bash.stdout, repos[0]) == normalize_repo_paths(
|
||||
ps.stdout, repos[1]
|
||||
) == normalize_repo_paths(py.stdout, repos[2])
|
||||
assert normalize_repo_paths(bash.stderr, repos[0]) == normalize_repo_paths(
|
||||
ps.stderr, repos[1]
|
||||
) == normalize_repo_paths(py.stderr, repos[2])
|
||||
for current in repos:
|
||||
assert (
|
||||
current / "specs" / "001-my-feature" / "plan.md"
|
||||
).read_text(encoding="utf-8") == TEMPLATE_BODY
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_python_json_output_matches_powershell(repo: Path) -> None:
|
||||
plan = repo / "specs" / "001-my-feature" / "plan.md"
|
||||
plan.write_text("# existing\n", encoding="utf-8")
|
||||
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
|
||||
|
||||
assert py.returncode == ps.returncode == 0
|
||||
assert json_stdout(py) == json_stdout(ps)
|
||||
207
tests/test_setup_tasks_python_parity.py
Normal file
207
tests/test_setup_tasks_python_parity.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""Parity tests for the Python setup-tasks port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
from tests.parity_helpers import (
|
||||
HAS_POWERSHELL,
|
||||
bash_cmd,
|
||||
clean_env,
|
||||
install_scripts,
|
||||
json_stdout,
|
||||
make_repo,
|
||||
normalize_status_text,
|
||||
ps_cmd,
|
||||
py_cmd,
|
||||
run,
|
||||
write_feature_json,
|
||||
)
|
||||
|
||||
SCRIPT = "setup-tasks"
|
||||
|
||||
|
||||
def _setup_repo(tmp_path: Path) -> Path:
|
||||
repo = make_repo(tmp_path)
|
||||
install_scripts(repo, SCRIPT)
|
||||
write_feature_json(repo)
|
||||
feature = repo / "specs" / "001-my-feature"
|
||||
feature.mkdir(parents=True)
|
||||
(feature / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feature / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
templates = repo / ".specify" / "templates"
|
||||
templates.mkdir(parents=True)
|
||||
(templates / "tasks-template.md").write_text("# Tasks Template\n", encoding="utf-8")
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path: Path) -> Path:
|
||||
return _setup_repo(tmp_path)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_json_output_matches_bash(repo: Path) -> None:
|
||||
feature = repo / "specs" / "001-my-feature"
|
||||
(feature / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feature / "data-model.md").write_text("# model\n", encoding="utf-8")
|
||||
(feature / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
|
||||
(feature / "contracts" / "v1").mkdir(parents=True)
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert json_stdout(py) == json_stdout(bash)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_text_output_matches_bash(repo: Path) -> None:
|
||||
feature = repo / "specs" / "001-my-feature"
|
||||
(feature / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feature / "contracts").mkdir() # present but empty -> reported missing
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT), repo)
|
||||
py = run(py_cmd(repo, SCRIPT), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert normalize_status_text(py.stdout) == normalize_status_text(bash.stdout)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_override_template_wins_matches_bash(repo: Path) -> None:
|
||||
overrides = repo / ".specify" / "templates" / "overrides"
|
||||
overrides.mkdir(parents=True)
|
||||
(overrides / "tasks-template.md").write_text("# Override\n", encoding="utf-8")
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert json_stdout(py) == json_stdout(bash)
|
||||
assert json_stdout(py)["TASKS_TEMPLATE"].endswith("overrides/tasks-template.md")
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"missing",
|
||||
["plan.md", "spec.md", "tasks-template"],
|
||||
ids=["missing_plan", "missing_spec", "missing_tasks_template"],
|
||||
)
|
||||
def test_python_error_output_matches_bash(repo: Path, missing: str) -> None:
|
||||
if missing == "tasks-template":
|
||||
(repo / ".specify" / "templates" / "tasks-template.md").unlink()
|
||||
else:
|
||||
(repo / "specs" / "001-my-feature" / missing).unlink()
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 1
|
||||
assert py.stdout == bash.stdout == ""
|
||||
assert py.stderr == bash.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_unknown_option_matches_bash(repo: Path) -> None:
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--bogus"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--bogus"), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 1
|
||||
assert py.stdout == bash.stdout == ""
|
||||
assert py.stderr == bash.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_powershell_unknown_option_matches_siblings(repo: Path) -> None:
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--bogus"), repo)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "--bogus"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--bogus"), repo)
|
||||
|
||||
assert ps.returncode == bash.returncode == py.returncode == 1
|
||||
assert ps.stdout == bash.stdout == py.stdout == ""
|
||||
assert ps.stderr == bash.stderr == py.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_help_beats_unknown_option_matches_bash(repo: Path) -> None:
|
||||
"""--help must win over a later unknown option and exit 0."""
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--help", "--bogus"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--help", "--bogus"), repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert "Usage" in py.stdout and "Usage" in bash.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_powershell_help_beats_unknown_option(repo: Path) -> None:
|
||||
"""-Help must win over unknown-argument validation like the siblings."""
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Help", "--bogus"), repo)
|
||||
|
||||
assert ps.returncode == 0, ps.stderr
|
||||
assert ps.stderr == ""
|
||||
assert "Usage" in ps.stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
"context", ["missing", "invalid_json", "invalid_utf8", "invalid_init_dir"]
|
||||
)
|
||||
def test_all_variants_feature_context_error_matches(
|
||||
tmp_path: Path, context: str
|
||||
) -> None:
|
||||
repo = make_repo(tmp_path)
|
||||
install_scripts(repo, SCRIPT)
|
||||
env = None
|
||||
if context == "invalid_json":
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
"{not json", encoding="utf-8"
|
||||
)
|
||||
elif context == "invalid_utf8":
|
||||
(repo / ".specify" / "feature.json").write_bytes(b"\xff")
|
||||
elif context == "invalid_init_dir":
|
||||
env = clean_env()
|
||||
env["SPECIFY_INIT_DIR"] = str(tmp_path / "missing")
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo, env)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json"), repo, env)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo, env)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
assert bash.stderr == ps.stderr == py.stderr
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_python_json_output_matches_powershell(repo: Path) -> None:
|
||||
feature = repo / "specs" / "001-my-feature"
|
||||
(feature / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feature / "contracts" / "v1").mkdir(parents=True)
|
||||
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
|
||||
|
||||
assert py.returncode == ps.returncode == 0
|
||||
assert json_stdout(py) == json_stdout(ps)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_missing_template_error_matches_all_variants(repo: Path) -> None:
|
||||
(repo / ".specify" / "templates" / "tasks-template.md").unlink()
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
assert bash.stderr == ps.stderr == py.stderr
|
||||
152
tests/test_skill_placeholder_py.py
Normal file
152
tests/test_skill_placeholder_py.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""resolve_skill_placeholders must support the py script variant (#3280)."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli._init_options import save_init_options
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
FRONTMATTER = {
|
||||
"scripts": {
|
||||
"sh": "scripts/bash/setup-plan.sh --json",
|
||||
"ps": "scripts/powershell/setup-plan.ps1 -Json",
|
||||
"py": "scripts/python/setup_plan.py --json",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _resolve(tmp_path: Path, script: str | None, monkeypatch) -> str:
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.IntegrationBase._interpreter_runs",
|
||||
staticmethod(lambda path: True),
|
||||
)
|
||||
if script:
|
||||
save_init_options(tmp_path, {"script": script})
|
||||
return CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex", FRONTMATTER, "Run {SCRIPT} now.", tmp_path
|
||||
)
|
||||
|
||||
|
||||
def test_py_variant_prefixes_interpreter(tmp_path, monkeypatch):
|
||||
body = _resolve(tmp_path, "py", monkeypatch)
|
||||
assert "python3 .specify/scripts/python/setup_plan.py --json" in body
|
||||
assert "{SCRIPT}" not in body
|
||||
|
||||
|
||||
def test_sh_variant_is_not_prefixed(tmp_path, monkeypatch):
|
||||
body = _resolve(tmp_path, "sh", monkeypatch)
|
||||
assert ".specify/scripts/bash/setup-plan.sh --json" in body
|
||||
assert "python3" not in body
|
||||
|
||||
|
||||
def test_py_interpreter_with_spaces_uses_powershell_call_operator(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
interpreter = r"C:\Program Files\Py$thon's\python.exe"
|
||||
quoted_interpreter = interpreter.replace("'", "''")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable",
|
||||
interpreter,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.os", SimpleNamespace(name="nt")
|
||||
)
|
||||
save_init_options(tmp_path, {"script": "py"})
|
||||
body = CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex", FRONTMATTER, "Run {SCRIPT} now.", tmp_path
|
||||
)
|
||||
assert f"& '{quoted_interpreter}' " in body
|
||||
|
||||
|
||||
def test_missing_py_variant_falls_back_to_available_script(tmp_path, monkeypatch):
|
||||
"""script=py with a template that only ships sh/ps must not leave {SCRIPT} unresolved."""
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
save_init_options(tmp_path, {"script": "py"})
|
||||
frontmatter = {
|
||||
"scripts": {
|
||||
"sh": "scripts/bash/setup-plan.sh --json",
|
||||
"ps": "scripts/powershell/setup-plan.ps1 -Json",
|
||||
}
|
||||
}
|
||||
body = CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex", frontmatter, "Run {SCRIPT} now.", tmp_path
|
||||
)
|
||||
assert "{SCRIPT}" not in body
|
||||
assert "setup-plan" in body
|
||||
|
||||
|
||||
def test_py_install_includes_python_and_fallback_scripts(tmp_path, monkeypatch):
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
_install_shared_infra(tmp_path, "py", force=True)
|
||||
|
||||
assert (tmp_path / ".specify/scripts/python/setup_plan.py").is_file()
|
||||
assert (tmp_path / ".specify/scripts/python/setup_tasks.py").is_file()
|
||||
|
||||
save_init_options(tmp_path, {"script": "py"})
|
||||
frontmatter = {
|
||||
"scripts": {
|
||||
"sh": "scripts/bash/check-prerequisites.sh --json",
|
||||
"ps": "scripts/powershell/check-prerequisites.ps1 -Json",
|
||||
}
|
||||
}
|
||||
body = CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex", frontmatter, "Run {SCRIPT} now.", tmp_path
|
||||
)
|
||||
fallback = (
|
||||
".specify/scripts/bash/check-prerequisites.sh"
|
||||
if "scripts/bash/" in body
|
||||
else ".specify/scripts/powershell/check-prerequisites.ps1"
|
||||
)
|
||||
assert (tmp_path / fallback).is_file()
|
||||
|
||||
|
||||
def test_py_rejects_one_sided_opposite_platform_fallback(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
from specify_cli import _install_shared_infra
|
||||
from specify_cli import shared_infra
|
||||
|
||||
class WindowsOs:
|
||||
name = "nt"
|
||||
|
||||
def __getattr__(self, attr):
|
||||
return getattr(os, attr)
|
||||
|
||||
monkeypatch.setattr(shared_infra, "os", WindowsOs())
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.platform.system", lambda: "Windows"
|
||||
)
|
||||
_install_shared_infra(tmp_path, "py", force=True)
|
||||
|
||||
save_init_options(tmp_path, {"script": "py"})
|
||||
frontmatter = {
|
||||
"scripts": {
|
||||
"sh": "scripts/bash/check-prerequisites.sh --json",
|
||||
}
|
||||
}
|
||||
with pytest.raises(ValueError, match="No runnable script variant"):
|
||||
CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex", frontmatter, "Run {SCRIPT} now.", tmp_path
|
||||
)
|
||||
|
||||
assert not (
|
||||
tmp_path / ".specify/scripts/bash/check-prerequisites.sh"
|
||||
).exists()
|
||||
@@ -1026,6 +1026,41 @@ class TestCommandStep:
|
||||
assert res_opt.status is StepStatus.FAILED
|
||||
assert "'options' must be a mapping" in (res_opt.error or "")
|
||||
|
||||
def test_validate_rejects_non_string_command(self):
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
|
||||
step = CommandStep()
|
||||
# execute() passes 'command' to build_command_invocation(), which does
|
||||
# command_name.startswith(...); a non-string crashes there with a raw
|
||||
# AttributeError. validate() must report it, like prompt-step 'prompt'.
|
||||
for bad in (None, ["a", "b"], 5, {"x": 1}):
|
||||
errs = step.validate({"id": "c", "command": bad})
|
||||
assert any("'command' must be a string" in e for e in errs), bad
|
||||
# a string command (incl. an expression) is still accepted
|
||||
assert step.validate({"id": "c", "command": "/x"}) == []
|
||||
assert step.validate({"id": "c", "command": "{{ inputs.cmd }}"}) == []
|
||||
|
||||
def test_execute_non_string_command_fails_cleanly(self):
|
||||
from unittest.mock import patch
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = CommandStep()
|
||||
# The engine may skip validate(); a non-string 'command' must FAIL the
|
||||
# step with the contract error rather than reaching _try_dispatch and
|
||||
# crashing build_command_invocation with a raw AttributeError. Force a
|
||||
# resolvable integration + installed CLI so, absent the guard, dispatch
|
||||
# would actually be attempted and the crash would fire.
|
||||
ctx = StepContext(default_integration="claude")
|
||||
with patch("specify_cli.workflows.steps.command.shutil.which",
|
||||
return_value="/usr/bin/claude"):
|
||||
for bad in (None, ["a", "b"], 5, {"x": 1}):
|
||||
result = step.execute(
|
||||
{"id": "c", "command": bad, "input": {}}, ctx
|
||||
)
|
||||
assert result.status is StepStatus.FAILED, bad
|
||||
assert "'command' must be a string" in (result.error or ""), bad
|
||||
|
||||
def test_step_override_integration(self):
|
||||
from unittest.mock import patch
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
@@ -1381,6 +1416,21 @@ class TestPromptStep:
|
||||
errors = step.validate({"id": "test"})
|
||||
assert any("missing 'prompt'" in e for e in errors)
|
||||
|
||||
@pytest.mark.parametrize("bad_prompt", [None, ["review", "this"], 42, {"a": 1}])
|
||||
def test_validate_rejects_non_string_prompt(self, bad_prompt):
|
||||
"""A non-string 'prompt' must be rejected at validation.
|
||||
|
||||
execute() str()-coerces prompt and dispatches it to the integration
|
||||
CLI, so a null or list prompt would otherwise send the Python repr to
|
||||
the model as instructions — silently wrong. Mirrors the shell-step
|
||||
'run' type check.
|
||||
"""
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
|
||||
step = PromptStep()
|
||||
errors = step.validate({"id": "p", "prompt": bad_prompt})
|
||||
assert any("'prompt' must be a string" in e for e in errors)
|
||||
|
||||
def test_validate_valid(self):
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
|
||||
@@ -1388,6 +1438,16 @@ class TestPromptStep:
|
||||
errors = step.validate({"id": "test", "prompt": "do something"})
|
||||
assert errors == []
|
||||
|
||||
def test_validate_accepts_expression_prompt(self):
|
||||
"""A '{{ ... }}' expression prompt is a str, so it stays valid."""
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
|
||||
step = PromptStep()
|
||||
errors = step.validate(
|
||||
{"id": "p", "prompt": "Review {{ inputs.file }}"}
|
||||
)
|
||||
assert errors == []
|
||||
|
||||
|
||||
class TestShellStep:
|
||||
"""Test the shell step type."""
|
||||
@@ -2068,6 +2128,72 @@ class TestGateStep:
|
||||
assert result.status == StepStatus.PAUSED
|
||||
assert result.output["show_file"] == "123"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_options",
|
||||
[5, {"a": "approve"}, None, [], "approve"],
|
||||
)
|
||||
def test_execute_non_list_options_fails_cleanly(self, monkeypatch, bad_options):
|
||||
"""A malformed ``options`` must FAIL the step, not crash the run.
|
||||
|
||||
``validate`` rejects a non-list/empty ``options``, but the engine does
|
||||
not auto-validate before ``execute``. On an interactive run a scalar/
|
||||
dict/None ``options`` would otherwise reach ``_prompt`` and raise a raw
|
||||
``TypeError`` (``enumerate``/``len`` on a non-iterable) or ``KeyError``
|
||||
(indexing a dict), crashing the whole workflow. Mirrors the switch
|
||||
'cases' and command 'input' unvalidated-execute guards."""
|
||||
from specify_cli.workflows.steps.gate import GateStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
# Force an interactive TTY so the crash-prone _prompt path is reached;
|
||||
# input() is stubbed so a (buggy) fall-through can't block the suite.
|
||||
_force_gate_stdin(monkeypatch, tty=True)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": "1")
|
||||
|
||||
step = GateStep()
|
||||
config = {"id": "review", "message": "Review.", "options": bad_options}
|
||||
result = step.execute(config, StepContext())
|
||||
|
||||
assert result.status == StepStatus.FAILED
|
||||
assert "options" in (result.error or "")
|
||||
assert result.output["choice"] is None
|
||||
|
||||
def test_execute_non_string_options_element_fails_cleanly(self, monkeypatch):
|
||||
"""A non-string option element must FAIL the step, not crash.
|
||||
|
||||
A non-empty list with a non-string element passes the shape check but
|
||||
would reach the reject test ``choice.lower()`` and raise a raw
|
||||
``AttributeError`` at run time. ``validate`` reports "must be strings";
|
||||
``execute`` must fail cleanly on an unvalidated run too."""
|
||||
from specify_cli.workflows.steps.gate import GateStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
_force_gate_stdin(monkeypatch, tty=True)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": "1")
|
||||
|
||||
step = GateStep()
|
||||
config = {"id": "review", "message": "Review.", "options": [123, 456]}
|
||||
result = step.execute(config, StepContext())
|
||||
|
||||
assert result.status == StepStatus.FAILED
|
||||
assert "options" in (result.error or "")
|
||||
|
||||
def test_execute_non_list_options_fails_in_non_tty_too(self):
|
||||
"""The guard runs before the non-TTY PAUSE short-circuit.
|
||||
|
||||
A malformed ``options`` should surface as FAILED in CI (non-TTY) rather
|
||||
than PAUSING and only crashing later when an operator resumes on a real
|
||||
terminal."""
|
||||
from specify_cli.workflows.steps.gate import GateStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
# Autouse fixture already forces non-TTY stdin.
|
||||
step = GateStep()
|
||||
config = {"id": "review", "message": "Review.", "options": 5}
|
||||
result = step.execute(config, StepContext())
|
||||
|
||||
assert result.status == StepStatus.FAILED
|
||||
assert "options" in (result.error or "")
|
||||
|
||||
|
||||
class TestIfThenStep:
|
||||
"""Test the if/then/else step type."""
|
||||
@@ -2684,6 +2810,39 @@ class TestFanOutStep:
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert result.output["item_count"] == 0
|
||||
|
||||
def test_execute_non_dict_step_fails_loudly(self):
|
||||
"""A truthy non-mapping ``step`` must fail the step, not crash the run.
|
||||
|
||||
``validate`` rejects a non-dict ``step``, but the engine's ``execute()``
|
||||
does not auto-validate (see ``WorkflowEngine.load_workflow``). On a
|
||||
COMPLETED fan-out the engine reads ``step_template`` back out and, when
|
||||
it is truthy, calls ``template.get("id", ...)`` in ``_run_fan_out``. A
|
||||
truthy non-mapping ``step`` (a scalar or list authoring mistake) raised
|
||||
AttributeError there and took down the whole run. Mirrors the fan-out
|
||||
non-list ``items`` guard and the switch non-dict ``cases`` guard.
|
||||
"""
|
||||
from specify_cli.workflows.steps.fan_out import FanOutStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = FanOutStep()
|
||||
ctx = StepContext(steps={"tasks": {"output": {"task_list": [1, 2]}}})
|
||||
# ``None`` is an explicit ``step: null``: ``config.get("step", {})`` only
|
||||
# substitutes the default for an *absent* key, so it reaches the guard
|
||||
# and must fail here too — matching ``validate``.
|
||||
for bad_step in (["impl"], "impl", 5, None):
|
||||
result = step.execute(
|
||||
{
|
||||
"id": "parallel",
|
||||
"items": "{{ steps.tasks.output.task_list }}",
|
||||
"step": bad_step,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
assert result.status == StepStatus.FAILED
|
||||
assert "'step' must be a" in (result.error or "")
|
||||
assert result.output["item_count"] == 0
|
||||
assert result.output["step_template"] == {}
|
||||
|
||||
def test_validate_missing_fields(self):
|
||||
from specify_cli.workflows.steps.fan_out import FanOutStep
|
||||
|
||||
@@ -2696,12 +2855,13 @@ class TestFanOutStep:
|
||||
from specify_cli.workflows.steps.fan_out import FanOutStep
|
||||
|
||||
step = FanOutStep()
|
||||
errors = step.validate({
|
||||
"id": "test",
|
||||
"items": "{{ x }}",
|
||||
"step": "not-a-dict",
|
||||
})
|
||||
assert any("'step' must be a mapping" in e for e in errors)
|
||||
for bad_step in ("not-a-dict", ["impl"], 5, None):
|
||||
errors = step.validate({
|
||||
"id": "test",
|
||||
"items": "{{ x }}",
|
||||
"step": bad_step,
|
||||
})
|
||||
assert any("'step' must be a mapping" in e for e in errors), bad_step
|
||||
|
||||
|
||||
class TestFanInStep:
|
||||
@@ -2784,6 +2944,34 @@ class TestFanInStep:
|
||||
assert "'wait_for' must be a list" in (result.error or "")
|
||||
assert result.output["results"] == []
|
||||
|
||||
@pytest.mark.parametrize("bad_entry", [["a", "b"], {"a": 1}, 123, None])
|
||||
def test_execute_non_string_wait_for_entry_fails_loudly(self, bad_entry):
|
||||
"""A ``wait_for`` list with a non-string entry must fail the step, not
|
||||
crash the run or silently produce a bogus join.
|
||||
|
||||
The whole-list guard (``test_execute_non_list_wait_for_fails_loudly``)
|
||||
and the engine's fan-in validation both already reject the list *shape*,
|
||||
but neither the step's ``execute`` nor the engine's runtime path guarded
|
||||
the list's *elements*. On an unvalidated run an unhashable entry
|
||||
(a list/dict from a YAML indentation slip like ``wait_for: [[a, b]]``)
|
||||
crashed ``context.steps.get(entry, ...)`` with a raw TypeError, while a
|
||||
hashable-but-non-string entry (``wait_for: [123]``) silently joined an
|
||||
empty ``{}`` and still reported COMPLETED — the same wiring bug the
|
||||
list-shape guard exists to prevent. Mirrors the engine's
|
||||
``test_non_string_wait_for_entry_is_rejected`` load-time check.
|
||||
"""
|
||||
from specify_cli.workflows.steps.fan_in import FanInStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = FanInStep()
|
||||
ctx = StepContext(steps={"a": {"output": {"x": 1}}})
|
||||
# A valid entry alongside the bad one proves it is the entry, not the
|
||||
# list, that is rejected.
|
||||
result = step.execute({"id": "collect", "wait_for": ["a", bad_entry]}, ctx)
|
||||
assert result.status == StepStatus.FAILED
|
||||
assert "'wait_for' entries must be step-id strings" in (result.error or "")
|
||||
assert result.output["results"] == []
|
||||
|
||||
def test_validate_empty_wait_for(self):
|
||||
from specify_cli.workflows.steps.fan_in import FanInStep
|
||||
|
||||
@@ -5775,6 +5963,26 @@ class TestWorkflowCatalog:
|
||||
assert len(entries) == 1
|
||||
assert entries[0].name == "custom"
|
||||
|
||||
@pytest.mark.parametrize("bad_priority", [True, False, float("inf")])
|
||||
def test_config_priority_bool_or_inf_rejected(self, project_dir, bad_priority):
|
||||
"""`priority: true` must not be silently coerced to 1, and `priority: .inf`
|
||||
must not crash with an uncaught OverflowError — both raise a clean
|
||||
validation error (parity with the base CatalogStackBase loader)."""
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError
|
||||
|
||||
config_path = project_dir / ".specify" / "workflow-catalogs.yml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"catalogs": [{
|
||||
"name": "bad",
|
||||
"url": "https://example.com/wf-catalog.json",
|
||||
"priority": bad_priority,
|
||||
"install_allowed": True,
|
||||
}]
|
||||
}))
|
||||
catalog = WorkflowCatalog(project_dir)
|
||||
with pytest.raises(WorkflowValidationError, match="Invalid priority|expected integer"):
|
||||
catalog.get_active_catalogs()
|
||||
|
||||
def test_validate_url_http_rejected(self, project_dir):
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError
|
||||
|
||||
@@ -5878,6 +6086,31 @@ class TestWorkflowCatalog:
|
||||
assert len(data["catalogs"]) == 1
|
||||
assert data["catalogs"][0]["url"] == "https://example.com/new-catalog.json"
|
||||
|
||||
def test_add_catalog_with_existing_inf_priority(self, project_dir):
|
||||
"""add_catalog() derives the new priority from existing ones via
|
||||
_coerce_priority; an existing `priority: .inf` must not crash it
|
||||
(int(float('inf')) is an OverflowError) — it is treated as 0 and the add
|
||||
succeeds."""
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog
|
||||
|
||||
config_path = project_dir / ".specify" / "workflow-catalogs.yml"
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(yaml.dump({
|
||||
"catalogs": [{
|
||||
"name": "existing",
|
||||
"url": "https://a.example.com/c.json",
|
||||
"priority": float("inf"),
|
||||
"install_allowed": True,
|
||||
}]
|
||||
}))
|
||||
|
||||
catalog = WorkflowCatalog(project_dir)
|
||||
catalog.add_catalog("https://b.example.com/c.json", "new")
|
||||
|
||||
data = yaml.safe_load(config_path.read_text())
|
||||
new = next(c for c in data["catalogs"] if c["url"] == "https://b.example.com/c.json")
|
||||
assert new["priority"] == 1 # max(inf coerced to 0) + 1
|
||||
|
||||
def test_add_catalog_duplicate_rejected(self, project_dir):
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError
|
||||
|
||||
@@ -6298,6 +6531,25 @@ class TestStepCatalog:
|
||||
assert len(entries) == 1
|
||||
assert entries[0].name == "custom"
|
||||
|
||||
@pytest.mark.parametrize("bad_priority", [True, False, float("inf")])
|
||||
def test_config_priority_bool_or_inf_rejected(self, project_dir, bad_priority):
|
||||
"""`priority: true`/`.inf` in a step-catalog config raise a clean
|
||||
validation error instead of coercing to 1 / crashing with OverflowError."""
|
||||
from specify_cli.workflows.catalog import StepCatalog, StepValidationError
|
||||
|
||||
config_path = project_dir / ".specify" / "step-catalogs.yml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"catalogs": [{
|
||||
"name": "bad",
|
||||
"url": "https://example.com/step-catalog.json",
|
||||
"priority": bad_priority,
|
||||
"install_allowed": True,
|
||||
}]
|
||||
}))
|
||||
catalog = StepCatalog(project_dir)
|
||||
with pytest.raises(StepValidationError, match="Invalid priority|expected integer"):
|
||||
catalog.get_active_catalogs()
|
||||
|
||||
def test_validate_url_http_rejected(self, project_dir):
|
||||
from specify_cli.workflows.catalog import StepCatalog, StepValidationError
|
||||
|
||||
@@ -6393,6 +6645,30 @@ class TestStepCatalog:
|
||||
assert len(data["catalogs"]) == 1
|
||||
assert data["catalogs"][0]["url"] == "https://example.com/new-steps.json"
|
||||
|
||||
def test_add_catalog_with_existing_inf_priority(self, project_dir):
|
||||
"""Step-catalog add_catalog() must not crash when an existing entry has a
|
||||
`priority: .inf` (int(float('inf')) is an OverflowError) — _coerce_priority
|
||||
treats it as 0 and the add succeeds."""
|
||||
from specify_cli.workflows.catalog import StepCatalog
|
||||
|
||||
config_path = project_dir / ".specify" / "step-catalogs.yml"
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(yaml.dump({
|
||||
"catalogs": [{
|
||||
"name": "existing",
|
||||
"url": "https://a.example.com/s.json",
|
||||
"priority": float("inf"),
|
||||
"install_allowed": True,
|
||||
}]
|
||||
}))
|
||||
|
||||
catalog = StepCatalog(project_dir)
|
||||
catalog.add_catalog("https://b.example.com/s.json", "new")
|
||||
|
||||
data = yaml.safe_load(config_path.read_text())
|
||||
new = next(c for c in data["catalogs"] if c["url"] == "https://b.example.com/s.json")
|
||||
assert new["priority"] == 1 # max(inf coerced to 0) + 1
|
||||
|
||||
def test_add_catalog_empty_yaml_file(self, project_dir):
|
||||
"""An empty YAML config file should be treated as empty, not corrupted."""
|
||||
from specify_cli.workflows.catalog import StepCatalog
|
||||
@@ -6916,7 +7192,7 @@ class TestWorkflowRemoveGuard:
|
||||
assert "Invalid workflow ID" in result.output
|
||||
assert sentinel.read_text(encoding="utf-8") == "keep"
|
||||
|
||||
@pytest.mark.parametrize("workflow_id", ["runs", "steps"])
|
||||
@pytest.mark.parametrize("workflow_id", ["overlays", "runs", "steps"])
|
||||
def test_remove_rejects_reserved_storage_ids(
|
||||
self, project_dir, monkeypatch, workflow_id
|
||||
):
|
||||
@@ -7302,9 +7578,39 @@ steps:
|
||||
# Literal bracketed text survives; Rich did not consume it as a tag.
|
||||
assert "[red]evil[/red]" in out
|
||||
|
||||
def test_add_rejects_reserved_overlay_storage_id(self, temp_dir, monkeypatch):
|
||||
"""workflow add must not install into the overlay storage directory."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
|
||||
overlay_file = temp_dir / "incoming.yml"
|
||||
overlay_file.write_text(
|
||||
"""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "overlays"
|
||||
name: "Bad Workflow"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: step-one
|
||||
command: speckit.specify
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", str(overlay_file)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Invalid workflow ID" in result.output
|
||||
assert not (temp_dir / ".specify" / "workflows" / "overlays" / "workflow.yml").exists()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"workflow_id",
|
||||
[
|
||||
"overlays",
|
||||
"runs",
|
||||
"steps",
|
||||
"nested/workflow",
|
||||
@@ -13000,6 +13306,58 @@ steps:
|
||||
assert result.exit_code != 0
|
||||
assert "Run not found: nonexistent-run" in result.output
|
||||
|
||||
def test_status_json_not_found_error_goes_to_stderr(
|
||||
self, project_dir, monkeypatch, capsys
|
||||
):
|
||||
"""Under --json, the not-found/invalid-run error must go to stderr so the
|
||||
stdout JSON stream stays parseable (empty on the error path) — mirroring
|
||||
`workflow run`/`workflow resume`. Before this fix both handlers used the
|
||||
stdout console, corrupting a consumer's json.loads(stdout)."""
|
||||
import typer
|
||||
from specify_cli.workflows import _commands
|
||||
|
||||
(project_dir / ".specify" / "workflows").mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(
|
||||
_commands, "_require_specify_project", lambda: project_dir
|
||||
)
|
||||
with pytest.raises(typer.Exit) as exc:
|
||||
_commands.workflow_status("does-not-exist", json_output=True)
|
||||
assert exc.value.exit_code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "Run not found" in captured.err
|
||||
assert "Run not found" not in captured.out
|
||||
# stdout carries no partial/corrupt JSON on the error path.
|
||||
assert captured.out.strip() == ""
|
||||
|
||||
def test_status_json_invalid_run_error_goes_to_stderr(
|
||||
self, project_dir, monkeypatch, capsys
|
||||
):
|
||||
"""The ValueError handler (a malformed/invalid run state) must ALSO route
|
||||
to stderr under --json, not just the FileNotFoundError one — otherwise a
|
||||
regression there would silently corrupt the JSON stream and this suite
|
||||
wouldn't catch it."""
|
||||
import typer
|
||||
from specify_cli.workflows import _commands
|
||||
from specify_cli.workflows.engine import RunState
|
||||
|
||||
(project_dir / ".specify" / "workflows").mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(
|
||||
_commands, "_require_specify_project", lambda: project_dir
|
||||
)
|
||||
|
||||
def _raise_value_error(*args, **kwargs):
|
||||
raise ValueError("corrupt run state: bad status")
|
||||
|
||||
monkeypatch.setattr(RunState, "load", _raise_value_error)
|
||||
|
||||
with pytest.raises(typer.Exit) as exc:
|
||||
_commands.workflow_status("some-run", json_output=True)
|
||||
assert exc.value.exit_code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "corrupt run state" in captured.err
|
||||
assert "corrupt run state" not in captured.out
|
||||
assert captured.out.strip() == ""
|
||||
|
||||
def test_status_no_run_id_list_path_unaffected(self, project_dir, monkeypatch):
|
||||
"""The no-run-id list-all-runs path must remain unaffected by the
|
||||
new single-run ValueError boundary."""
|
||||
|
||||
@@ -71,6 +71,48 @@ def test_http_fetch_rejects_non_https_final_url(monkeypatch):
|
||||
fetcher(_source("https://example.com/c.json"))
|
||||
|
||||
|
||||
def test_builtin_community_catalog_fetches_repository_catalog_online(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def fake_http_get_json(source_id, url):
|
||||
captured["source_id"] = source_id
|
||||
captured["url"] = url
|
||||
return {"schema_version": "1.0", "bundles": {}}
|
||||
|
||||
monkeypatch.setattr(adapters, "_http_get_json", fake_http_get_json)
|
||||
|
||||
fetcher = adapters.make_catalog_fetcher(allow_network=True)
|
||||
result = fetcher(_source("builtin://community"))
|
||||
|
||||
assert result["bundles"] == {}
|
||||
assert captured == {
|
||||
"source_id": "team",
|
||||
"url": adapters.COMMUNITY_CATALOG_URL,
|
||||
}
|
||||
|
||||
|
||||
def test_builtin_community_catalog_uses_core_pack_snapshot_offline(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
catalog_path = tmp_path / "bundles" / "catalog.community.json"
|
||||
catalog_path.parent.mkdir()
|
||||
catalog_path.write_text(
|
||||
'{"schema_version":"1.0","bundles":{"packaged":{'
|
||||
'"id":"packaged","name":"Packaged","version":"1.0.0",'
|
||||
'"role":"developer","description":"Packaged catalog entry.",'
|
||||
'"author":"Spec Kit","license":"MIT","download_url":"",'
|
||||
'"requires":{"speckit_version":">=0.1.0"},'
|
||||
'"provides":{},"verified":false}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(adapters, "_locate_core_pack", lambda: tmp_path)
|
||||
|
||||
fetcher = adapters.make_catalog_fetcher(allow_network=False)
|
||||
result = fetcher(_source("builtin://community"))
|
||||
|
||||
assert "packaged" in result["bundles"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
|
||||
26
tests/workflows/conftest.py
Normal file
26
tests/workflows/conftest.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Shared fixtures for workflow tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Create a temporary directory for tests."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
yield Path(tmpdir)
|
||||
shutil.rmtree(tmpdir, ignore_errors=(sys.platform == "win32"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(temp_dir):
|
||||
"""Create a mock spec-kit project with ``.specify/workflows/`` directory."""
|
||||
workflows_dir = temp_dir / ".specify" / "workflows"
|
||||
workflows_dir.mkdir(parents=True, exist_ok=True)
|
||||
return temp_dir
|
||||
815
tests/workflows/test_overlay_commands.py
Normal file
815
tests/workflows/test_overlay_commands.py
Normal file
@@ -0,0 +1,815 @@
|
||||
"""Tests for workflow overlay CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(tmp_path):
|
||||
"""Create a mock spec-kit project with ``.specify/workflows/`` directory."""
|
||||
workflows_dir = tmp_path / ".specify" / "workflows"
|
||||
workflows_dir.mkdir(parents=True, exist_ok=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _write_workflow(project_root: Path, workflow_id: str, data: dict) -> Path:
|
||||
wf_dir = project_root / ".specify" / "workflows" / workflow_id
|
||||
wf_dir.mkdir(parents=True, exist_ok=True)
|
||||
wf_path = wf_dir / "workflow.yml"
|
||||
wf_path.write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
return wf_path
|
||||
|
||||
|
||||
def _write_overlay(project_root: Path, workflow_id: str, overlay_id: str, data: dict) -> Path:
|
||||
ov_dir = project_root / ".specify" / "workflows" / "overlays" / workflow_id
|
||||
ov_dir.mkdir(parents=True, exist_ok=True)
|
||||
ov_path = ov_dir / f"{overlay_id}.yml"
|
||||
ov_path.write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
return ov_path
|
||||
|
||||
|
||||
class TestOverlayCli:
|
||||
"""CLI-level tests for ``specify workflow overlay *``."""
|
||||
|
||||
def test_overlay_add(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
overlay_file = project_dir / "overlay.yml"
|
||||
overlay_file.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["workflow", "overlay", "add", str(overlay_file), "--priority", "5"]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Overlay 'ov1' added" in result.output
|
||||
|
||||
installed = project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
|
||||
assert installed.is_file()
|
||||
data = yaml.safe_load(installed.read_text(encoding="utf-8"))
|
||||
assert data["priority"] == 5
|
||||
|
||||
def test_overlay_add_reuses_yaml_extension(self, project_dir, monkeypatch):
|
||||
"""If <id>.yaml already exists, overlay add must write to it instead of creating <id>.yml."""
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
# Pre-create the overlay using the .yaml extension.
|
||||
existing_yaml = project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yaml"
|
||||
existing_yaml.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing_yaml.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 1,
|
||||
"edits": [{"remove": "a"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
overlay_file = project_dir / "overlay.yml"
|
||||
overlay_file.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 20,
|
||||
"edits": [{"remove": "a"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "add", str(overlay_file)])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# Should have written to the pre-existing .yaml file.
|
||||
assert existing_yaml.is_file()
|
||||
data = yaml.safe_load(existing_yaml.read_text(encoding="utf-8"))
|
||||
assert data["priority"] == 10
|
||||
|
||||
# Must NOT have created a duplicate .yml alongside the .yaml.
|
||||
duplicate_yml = existing_yaml.with_suffix(".yml")
|
||||
assert not duplicate_yml.exists(), "duplicate .yml was created alongside existing .yaml"
|
||||
assert list(existing_yaml.parent.glob(f".{existing_yaml.name}.*.bak")) == []
|
||||
|
||||
def test_overlay_add_with_priority_override_missing_in_file(self, project_dir, monkeypatch):
|
||||
"""--priority must fix a missing priority in the overlay file."""
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
# Overlay file has NO priority field
|
||||
overlay_file = project_dir / "overlay.yml"
|
||||
overlay_file.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["workflow", "overlay", "add", str(overlay_file), "--priority", "5"]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Overlay 'ov1' added" in result.output
|
||||
|
||||
installed = project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
|
||||
assert installed.is_file()
|
||||
data = yaml.safe_load(installed.read_text(encoding="utf-8"))
|
||||
assert data["priority"] == 5
|
||||
|
||||
def test_overlay_add_defaults_priority_to_ten(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
overlay_file = project_dir / "overlay.yml"
|
||||
overlay_file.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"edits": [{"remove": "a"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "add", str(overlay_file)])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
installed = project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
|
||||
assert yaml.safe_load(installed.read_text(encoding="utf-8"))["priority"] == 10
|
||||
|
||||
def test_overlay_add_rejects_non_positive_priority(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
overlay_file = project_dir / "overlay.yml"
|
||||
overlay_file.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"edits": [{"remove": "a"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["workflow", "overlay", "add", str(overlay_file), "--priority", "0"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "must be >= 1" in result.output
|
||||
|
||||
def test_overlay_set_priority(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["workflow", "overlay", "set-priority", "wf", "ov1", "20"]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
data = yaml.safe_load(
|
||||
(
|
||||
project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
assert data["priority"] == 20
|
||||
assert list(
|
||||
(project_dir / ".specify" / "workflows" / "overlays" / "wf").glob(
|
||||
".ov1.yml.*.bak"
|
||||
)
|
||||
) == []
|
||||
|
||||
def test_overlay_set_priority_rejects_zero(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["workflow", "overlay", "set-priority", "wf", "ov1", "0"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "must be >= 1" in result.output
|
||||
|
||||
def test_overlay_set_priority_rejects_ids_with_trailing_newline(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["workflow", "overlay", "set-priority", "wf", "ov1\n", "20"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid overlay ID" in result.output
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["workflow", "overlay", "set-priority", "wf\n", "ov1", "20"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid workflow ID" in result.output
|
||||
|
||||
def test_overlay_disable_and_enable(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "disable", "wf", "ov1"])
|
||||
assert result.exit_code == 0, result.output
|
||||
data = yaml.safe_load(
|
||||
(
|
||||
project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
assert data["enabled"] is False
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "enable", "wf", "ov1"])
|
||||
assert result.exit_code == 0, result.output
|
||||
data = yaml.safe_load(
|
||||
(
|
||||
project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
assert data["enabled"] is True
|
||||
|
||||
def test_overlay_remove(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "remove", "wf", "ov1"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert not (
|
||||
project_dir / ".specify" / "workflows" / "overlays" / "wf" / "ov1.yml"
|
||||
).exists()
|
||||
|
||||
def test_overlay_list(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "list", "wf"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "ov1" in result.output
|
||||
|
||||
def test_overlay_list_shows_disabled_overlay(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"enabled": False,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "list", "wf"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "ov1" in result.output
|
||||
assert "disabled" in result.output
|
||||
|
||||
def test_workflow_resolve(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "resolve", "wf"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "base" in result.output
|
||||
assert "project:ov1" in result.output
|
||||
assert "new" in result.output
|
||||
assert "priority=n/a" in result.output
|
||||
|
||||
from specify_cli.workflows.overlays._commands import workflow_resolve
|
||||
|
||||
payload = workflow_resolve(project_dir, "wf")
|
||||
assert payload is not None
|
||||
assert payload["layers"][-1]["tier"] == "base"
|
||||
assert payload["layers"][-1]["priority"] is None
|
||||
|
||||
def test_workflow_resolve_equal_priority_layers_sort_by_source(self, project_dir, monkeypatch):
|
||||
"""Equal-priority overlays are listed alphabetically by source."""
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
# "zzz" sorts last alphabetically, so the composer applies it last and wins.
|
||||
# Resolver layer output follows the common priority/source sort order.
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"aaa",
|
||||
{
|
||||
"id": "aaa",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "aaa-step", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"zzz",
|
||||
{
|
||||
"id": "zzz",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "zzz-step", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "resolve", "wf"])
|
||||
assert result.exit_code == 0, result.output
|
||||
zzz_pos = result.output.index("project:zzz")
|
||||
aaa_pos = result.output.index("project:aaa")
|
||||
assert aaa_pos < zzz_pos
|
||||
|
||||
def test_workflow_add_does_not_copy_overlays(self, project_dir, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
source_dir = tmp_path / "source-wf"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "workflow.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
overlays_dir = source_dir / "overlays"
|
||||
overlays_dir.mkdir()
|
||||
(overlays_dir / "ov1.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "add", str(source_dir)])
|
||||
assert result.exit_code == 0, result.output
|
||||
# Overlays in the source directory should NOT be copied — workflow add
|
||||
# only installs the workflow.yml, not sibling overlays.
|
||||
installed_overlay = (
|
||||
project_dir / ".specify" / "workflows" / "wf" / "overlays" / "ov1.yml"
|
||||
)
|
||||
assert not installed_overlay.exists()
|
||||
|
||||
|
||||
class TestOverlayFilenameVsManifestId:
|
||||
"""Overlay identity must come from the manifest ``id`` field, not the filename.
|
||||
|
||||
This matches the project-wide convention: presets use ``preset.id``,
|
||||
extensions use ``extension.id``, workflows use ``workflow.id``, and
|
||||
workflow steps use ``step.type_key``. Overlays must follow the same pattern.
|
||||
"""
|
||||
|
||||
def _write_mismatched_overlay(
|
||||
self, project_root: Path, workflow_id: str, filename: str, manifest_id: str, data: dict
|
||||
) -> Path:
|
||||
"""Write an overlay file where filename != manifest id."""
|
||||
ov_dir = project_root / ".specify" / "workflows" / "overlays" / workflow_id
|
||||
ov_dir.mkdir(parents=True, exist_ok=True)
|
||||
ov_path = ov_dir / filename
|
||||
ov_path.write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
return ov_path
|
||||
|
||||
def test_find_overlay_by_manifest_id_not_filename(self, project_dir, monkeypatch):
|
||||
"""_find_overlay_file must locate overlays by manifest id, not filename."""
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
# File is named "custom.yml" but manifest declares id: "lint"
|
||||
self._write_mismatched_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"custom.yml",
|
||||
"lint",
|
||||
{
|
||||
"id": "lint",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [{"remove": "a"}],
|
||||
},
|
||||
)
|
||||
|
||||
from specify_cli.workflows.overlays._commands import _find_overlay_file
|
||||
|
||||
# Must find by manifest id "lint", not by filename "custom"
|
||||
found = _find_overlay_file(project_dir, "wf", "lint")
|
||||
assert found is not None
|
||||
assert found.name == "custom.yml"
|
||||
|
||||
# Must NOT find by filename stem "custom"
|
||||
not_found = _find_overlay_file(project_dir, "wf", "custom")
|
||||
assert not_found is None
|
||||
|
||||
def test_enable_disable_with_mismatched_filename(self, project_dir, monkeypatch):
|
||||
"""enable/disable must work when filename != manifest id."""
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
self._write_mismatched_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"custom.yml",
|
||||
"lint",
|
||||
{
|
||||
"id": "lint",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [{"remove": "a"}],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "disable", "wf", "lint"])
|
||||
assert result.exit_code == 0, result.output
|
||||
data = yaml.safe_load(
|
||||
(project_dir / ".specify" / "workflows" / "overlays" / "wf" / "custom.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert data["enabled"] is False
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "enable", "wf", "lint"])
|
||||
assert result.exit_code == 0, result.output
|
||||
data = yaml.safe_load(
|
||||
(project_dir / ".specify" / "workflows" / "overlays" / "wf" / "custom.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert data["enabled"] is True
|
||||
|
||||
def test_set_priority_with_mismatched_filename(self, project_dir, monkeypatch):
|
||||
"""set-priority must work when filename != manifest id."""
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
self._write_mismatched_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"custom.yml",
|
||||
"lint",
|
||||
{
|
||||
"id": "lint",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [{"remove": "a"}],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "set-priority", "wf", "lint", "25"])
|
||||
assert result.exit_code == 0, result.output
|
||||
data = yaml.safe_load(
|
||||
(project_dir / ".specify" / "workflows" / "overlays" / "wf" / "custom.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert data["priority"] == 25
|
||||
|
||||
def test_remove_with_mismatched_filename(self, project_dir, monkeypatch):
|
||||
"""remove must work when filename != manifest id."""
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
self._write_mismatched_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"custom.yml",
|
||||
"lint",
|
||||
{
|
||||
"id": "lint",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [{"remove": "a"}],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "remove", "wf", "lint"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert not (
|
||||
project_dir / ".specify" / "workflows" / "overlays" / "wf" / "custom.yml"
|
||||
).exists()
|
||||
|
||||
def test_duplicate_manifest_id_is_rejected(self, project_dir, monkeypatch):
|
||||
"""Two files with the same manifest ID are ambiguous."""
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
# Two files, both declare id: "lint"
|
||||
self._write_mismatched_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"aaa.yml",
|
||||
"lint",
|
||||
{
|
||||
"id": "lint",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [{"remove": "a"}],
|
||||
},
|
||||
)
|
||||
self._write_mismatched_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"zzz.yml",
|
||||
"lint",
|
||||
{
|
||||
"id": "lint",
|
||||
"extends": "wf",
|
||||
"priority": 20,
|
||||
"edits": [{"remove": "a"}],
|
||||
},
|
||||
)
|
||||
|
||||
from specify_cli.workflows.overlays._commands import _find_overlay_file
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
_find_overlay_file(project_dir, "wf", "lint")
|
||||
153
tests/workflows/test_overlay_composer.py
Normal file
153
tests/workflows/test_overlay_composer.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Tests for StepListComposer validation and error handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from specify_cli.workflows.overlays import WorkflowResolver
|
||||
from specify_cli.workflows.overlays.composer import StepListComposer
|
||||
from specify_cli.workflows.overlays.layer_sources import BaseWorkflowSource, Layer
|
||||
from specify_cli.workflows.overlays.schema import Overlay, OverlayEdit
|
||||
|
||||
|
||||
def _write_workflow(project_root: Path, workflow_id: str, data: dict) -> Path:
|
||||
wf_dir = project_root / ".specify" / "workflows" / workflow_id
|
||||
wf_dir.mkdir(parents=True, exist_ok=True)
|
||||
wf_path = wf_dir / "workflow.yml"
|
||||
wf_path.write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
return wf_path
|
||||
|
||||
|
||||
class TestStepListComposerValidation:
|
||||
"""Composer validates edits before applying them."""
|
||||
|
||||
def test_composer_reports_invalid_anchors(self, project_dir):
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
base_layer = BaseWorkflowSource(project_dir).collect("wf")[0]
|
||||
|
||||
overlay = Overlay(
|
||||
id="ov",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("insert_after", "missing", {"id": "new", "type": "command", "command": "echo"})],
|
||||
)
|
||||
layer = Layer(content=overlay, source="project:ov", tier="project-overlay", priority=10)
|
||||
composer = StepListComposer()
|
||||
with pytest.raises(ValueError, match="does not match any base step id"):
|
||||
composer.compose([base_layer, layer])
|
||||
|
||||
def test_composer_validates_edits_before_merge(self, project_dir):
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
overlay = Overlay(
|
||||
id="ov",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("replace", "a", {"id": "bad:id", "type": "command", "command": "echo"})],
|
||||
)
|
||||
layer = Layer(content=overlay, source="project:ov", tier="project-overlay", priority=10)
|
||||
composer = StepListComposer()
|
||||
with pytest.raises(ValueError, match="bad:id"):
|
||||
composer.compose([BaseWorkflowSource(project_dir).collect("wf")[0], layer])
|
||||
|
||||
def test_resolver_reports_invalid_anchor_as_validation_error(self, project_dir):
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
overlay_file = project_dir / "overlay.yml"
|
||||
overlay_file.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"id": "ov",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "missing",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Manually inject the overlay by writing it to disk in the correct location.
|
||||
overlay_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf"
|
||||
overlay_dir.mkdir(parents=True, exist_ok=True)
|
||||
(overlay_dir / "ov.yml").write_text(overlay_file.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
with pytest.raises(ValueError, match="missing"):
|
||||
resolver.resolve("wf")
|
||||
|
||||
def test_composer_applies_lower_priority_last(self, project_dir):
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "base"}],
|
||||
},
|
||||
)
|
||||
high_number = Overlay(
|
||||
id="high-number",
|
||||
extends="wf",
|
||||
priority=20,
|
||||
edits=[
|
||||
OverlayEdit(
|
||||
"replace",
|
||||
"a",
|
||||
{"id": "a", "type": "command", "command": "priority-20"},
|
||||
)
|
||||
],
|
||||
)
|
||||
low_number = Overlay(
|
||||
id="low-number",
|
||||
extends="wf",
|
||||
priority=5,
|
||||
edits=[
|
||||
OverlayEdit(
|
||||
"replace",
|
||||
"a",
|
||||
{"id": "a", "type": "command", "command": "priority-5"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
definition, attribution = StepListComposer().compose(
|
||||
[
|
||||
BaseWorkflowSource(project_dir).collect("wf")[0],
|
||||
Layer(high_number, "project:high-number", "project-overlay", 20),
|
||||
Layer(low_number, "project:low-number", "project-overlay", 5),
|
||||
]
|
||||
)
|
||||
|
||||
assert definition is not None
|
||||
assert definition.data["steps"][0]["command"] == "priority-5"
|
||||
assert attribution[0].source == "project:low-number"
|
||||
272
tests/workflows/test_overlay_layer_sources.py
Normal file
272
tests/workflows/test_overlay_layer_sources.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""Tests for ProjectOverlaySource and BaseWorkflowSource."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from specify_cli.workflows.overlays.layer_sources import (
|
||||
BaseWorkflowSource,
|
||||
OverlayLoadError,
|
||||
ProjectOverlaySource,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(tmp_path: Path) -> Path:
|
||||
workflows_dir = tmp_path / ".specify" / "workflows"
|
||||
workflows_dir.mkdir(parents=True, exist_ok=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _write_overlay_file(project_dir: Path, workflow_id: str, overlay_id: str, data: dict) -> Path:
|
||||
ov_dir = project_dir / ".specify" / "workflows" / "overlays" / workflow_id
|
||||
ov_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = ov_dir / f"{overlay_id}.yml"
|
||||
path.write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class TestProjectOverlaySourceFileReadErrors:
|
||||
"""File-read errors must be wrapped in OverlayLoadError, not leaked as raw tracebacks."""
|
||||
|
||||
def test_oserror_raises_overlay_load_error(self, project_dir: Path) -> None:
|
||||
"""An OSError from read_text (e.g. permission denied) is wrapped in OverlayLoadError."""
|
||||
_write_overlay_file(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{"id": "ov1", "extends": "wf", "priority": 5, "edits": []},
|
||||
)
|
||||
source = ProjectOverlaySource(project_dir)
|
||||
with patch.object(Path, "read_text", side_effect=OSError("Permission denied")):
|
||||
with pytest.raises(OverlayLoadError) as exc_info:
|
||||
source.collect("wf")
|
||||
assert exc_info.value.errors, "OverlayLoadError must carry a non-empty errors list"
|
||||
|
||||
def test_unicode_error_raises_overlay_load_error(self, project_dir: Path) -> None:
|
||||
"""A file containing non-UTF-8 bytes raises OverlayLoadError, not UnicodeDecodeError."""
|
||||
ov_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf"
|
||||
ov_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Write raw invalid UTF-8 bytes directly so read_text(encoding="utf-8") fails.
|
||||
bad_file = ov_dir / "bad.yml"
|
||||
bad_file.write_bytes(b"\xff\xfe invalid utf-8")
|
||||
|
||||
source = ProjectOverlaySource(project_dir)
|
||||
with pytest.raises(OverlayLoadError) as exc_info:
|
||||
source.collect("wf")
|
||||
assert exc_info.value.errors, "OverlayLoadError must carry a non-empty errors list"
|
||||
|
||||
|
||||
_UNSAFE_IDS = [
|
||||
"../outside",
|
||||
"../../escape",
|
||||
"nested/workflow",
|
||||
"wf\n",
|
||||
"overlays",
|
||||
"runs",
|
||||
"steps",
|
||||
"",
|
||||
"/absolute",
|
||||
"UPPER",
|
||||
"has space",
|
||||
]
|
||||
|
||||
|
||||
class TestProjectOverlaySourceIdValidation:
|
||||
"""ProjectOverlaySource.collect() must reject unsafe IDs before path construction."""
|
||||
|
||||
@pytest.mark.parametrize("workflow_id", _UNSAFE_IDS)
|
||||
def test_rejects_unsafe_id(self, project_dir: Path, workflow_id: str) -> None:
|
||||
source = ProjectOverlaySource(project_dir)
|
||||
with pytest.raises(OverlayLoadError, match="Invalid workflow ID"):
|
||||
source.collect(workflow_id)
|
||||
|
||||
@pytest.mark.parametrize("workflow_id", _UNSAFE_IDS)
|
||||
def test_does_not_access_filesystem_for_unsafe_id(
|
||||
self, project_dir: Path, workflow_id: str
|
||||
) -> None:
|
||||
"""No directory walk or file read should happen for an invalid ID."""
|
||||
source = ProjectOverlaySource(project_dir)
|
||||
with patch.object(Path, "iterdir", side_effect=AssertionError("iterdir called")):
|
||||
with pytest.raises(OverlayLoadError, match="Invalid workflow ID"):
|
||||
source.collect(workflow_id)
|
||||
|
||||
|
||||
class TestProjectOverlaySourceContainment:
|
||||
"""ProjectOverlaySource.collect() must enforce containment of the workflow overlay dir."""
|
||||
|
||||
def test_rejects_symlinked_workflow_overlay_dir(self, project_dir: Path, tmp_path: Path) -> None:
|
||||
"""A symlinked per-workflow overlay directory must be rejected."""
|
||||
real_dir = tmp_path / "real-overlay"
|
||||
real_dir.mkdir()
|
||||
overlay_root = project_dir / ".specify" / "workflows" / "overlays"
|
||||
overlay_root.mkdir(parents=True, exist_ok=True)
|
||||
link = overlay_root / "wf"
|
||||
link.symlink_to(real_dir)
|
||||
|
||||
source = ProjectOverlaySource(project_dir)
|
||||
with pytest.raises(OverlayLoadError, match="Symlinked overlay directories are not allowed"):
|
||||
source.collect("wf")
|
||||
|
||||
def test_rejects_workflow_overlay_dir_escaping_root(
|
||||
self, project_dir: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""A workflow overlay dir that resolves outside the overlay root must be rejected.
|
||||
|
||||
This requires the ID itself to pass validation but the resolved path to escape —
|
||||
which is possible if the overlay root itself is a junction/mount that resolves
|
||||
outside the project root; or in edge cases on case-insensitive file systems.
|
||||
We simulate it by patching Path.resolve to return an outside path.
|
||||
"""
|
||||
overlay_root = project_dir / ".specify" / "workflows" / "overlays"
|
||||
overlay_root.mkdir(parents=True, exist_ok=True)
|
||||
workflow_overlay_dir = overlay_root / "wf"
|
||||
workflow_overlay_dir.mkdir()
|
||||
|
||||
outside = tmp_path / "outside" / "wf"
|
||||
outside.mkdir(parents=True)
|
||||
|
||||
original_resolve = Path.resolve
|
||||
|
||||
def fake_resolve(self: Path, **kwargs: object) -> Path:
|
||||
if self == workflow_overlay_dir:
|
||||
return outside
|
||||
return original_resolve(self, **kwargs)
|
||||
|
||||
source = ProjectOverlaySource(project_dir)
|
||||
with patch.object(Path, "resolve", fake_resolve):
|
||||
with pytest.raises(OverlayLoadError, match="Path traversal detected"):
|
||||
source.collect("wf")
|
||||
|
||||
|
||||
class TestProjectOverlaySourceDisabledFiltering:
|
||||
"""ProjectOverlaySource.collect() should expose disabled entries only on opt-in."""
|
||||
|
||||
def test_skips_disabled_by_default(self, project_dir: Path) -> None:
|
||||
_write_overlay_file(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 5,
|
||||
"enabled": False,
|
||||
"edits": [{"remove": "a"}],
|
||||
},
|
||||
)
|
||||
|
||||
source = ProjectOverlaySource(project_dir)
|
||||
assert source.collect("wf") == []
|
||||
|
||||
def test_can_include_disabled_for_management_views(self, project_dir: Path) -> None:
|
||||
_write_overlay_file(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 5,
|
||||
"enabled": False,
|
||||
"edits": [{"remove": "a"}],
|
||||
},
|
||||
)
|
||||
|
||||
source = ProjectOverlaySource(project_dir)
|
||||
layers = source.collect("wf", include_disabled=True)
|
||||
assert [layer.content.id for layer in layers] == ["ov1"]
|
||||
assert layers[0].content.enabled is False
|
||||
|
||||
def test_skips_invalid_disabled_overlay_during_resolution(self, project_dir: Path) -> None:
|
||||
_write_overlay_file(
|
||||
project_dir,
|
||||
"wf",
|
||||
"disabled",
|
||||
{
|
||||
"id": "disabled",
|
||||
"extends": "wf",
|
||||
"enabled": False,
|
||||
"edits": "not-a-list",
|
||||
},
|
||||
)
|
||||
|
||||
source = ProjectOverlaySource(project_dir)
|
||||
assert source.collect("wf") == []
|
||||
with pytest.raises(OverlayLoadError, match="edits"):
|
||||
source.collect("wf", include_disabled=True)
|
||||
|
||||
def test_rejects_duplicate_manifest_ids(self, project_dir: Path) -> None:
|
||||
data = {
|
||||
"id": "duplicate",
|
||||
"extends": "wf",
|
||||
"edits": [{"remove": "a"}],
|
||||
}
|
||||
_write_overlay_file(project_dir, "wf", "first", data)
|
||||
_write_overlay_file(project_dir, "wf", "second", data)
|
||||
|
||||
with pytest.raises(OverlayLoadError, match="Duplicate overlay id"):
|
||||
ProjectOverlaySource(project_dir).collect("wf")
|
||||
|
||||
|
||||
class TestBaseWorkflowSourceIdValidation:
|
||||
"""BaseWorkflowSource.collect() must reject unsafe IDs before path construction."""
|
||||
|
||||
@pytest.mark.parametrize("workflow_id", _UNSAFE_IDS)
|
||||
def test_rejects_unsafe_id(self, project_dir: Path, workflow_id: str) -> None:
|
||||
source = BaseWorkflowSource(project_dir)
|
||||
with pytest.raises(OverlayLoadError, match="Invalid workflow ID"):
|
||||
source.collect(workflow_id)
|
||||
|
||||
|
||||
class TestBaseWorkflowSourceContainment:
|
||||
"""BaseWorkflowSource.collect() must enforce the same checks as _safe_workflow_id_dir."""
|
||||
|
||||
def test_rejects_symlinked_workflow_dir(self, project_dir: Path, tmp_path: Path) -> None:
|
||||
"""A symlinked workflow directory must be rejected."""
|
||||
real_dir = tmp_path / "real-wf"
|
||||
real_dir.mkdir()
|
||||
(real_dir / "workflow.yml").write_text("schema_version: '1.0'\n", encoding="utf-8")
|
||||
|
||||
workflows_dir = project_dir / ".specify" / "workflows"
|
||||
workflows_dir.mkdir(parents=True, exist_ok=True)
|
||||
link = workflows_dir / "wf"
|
||||
link.symlink_to(real_dir)
|
||||
|
||||
source = BaseWorkflowSource(project_dir)
|
||||
with pytest.raises(OverlayLoadError, match="Symlinked overlay directories are not allowed"):
|
||||
source.collect("wf")
|
||||
|
||||
def test_rejects_symlinked_workflow_yml(self, project_dir: Path, tmp_path: Path) -> None:
|
||||
"""A symlinked workflow.yml must be rejected even if the directory is real."""
|
||||
real_yml = tmp_path / "workflow.yml"
|
||||
real_yml.write_text("schema_version: '1.0'\n", encoding="utf-8")
|
||||
|
||||
workflows_dir = project_dir / ".specify" / "workflows"
|
||||
wf_dir = workflows_dir / "wf"
|
||||
wf_dir.mkdir(parents=True, exist_ok=True)
|
||||
link = wf_dir / "workflow.yml"
|
||||
link.symlink_to(real_yml)
|
||||
|
||||
source = BaseWorkflowSource(project_dir)
|
||||
with pytest.raises(OverlayLoadError, match="Symlinked workflow files are not allowed"):
|
||||
source.collect("wf")
|
||||
|
||||
def test_missing_workflow_returns_empty(self, project_dir: Path) -> None:
|
||||
"""A workflow directory that does not exist returns an empty layer list."""
|
||||
source = BaseWorkflowSource(project_dir)
|
||||
assert source.collect("no-such-wf") == []
|
||||
|
||||
def test_rejects_symlinked_workflows_root(self, project_dir: Path, tmp_path: Path) -> None:
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
workflows_dir = project_dir / ".specify" / "workflows"
|
||||
workflows_dir.rmdir()
|
||||
workflows_dir.symlink_to(outside)
|
||||
|
||||
with pytest.raises(OverlayLoadError, match="Symlinked workflow directories"):
|
||||
BaseWorkflowSource(project_dir).collect("wf")
|
||||
718
tests/workflows/test_overlay_merge.py
Normal file
718
tests/workflows/test_overlay_merge.py
Normal file
@@ -0,0 +1,718 @@
|
||||
"""Tests for the workflow overlay merge engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.workflows.overlays.merge import (
|
||||
ComposedStep,
|
||||
OverlayLayer,
|
||||
find_step,
|
||||
merge_steps,
|
||||
validate_edits,
|
||||
)
|
||||
from specify_cli.workflows.overlays.schema import Overlay, OverlayEdit
|
||||
|
||||
|
||||
def _step(id: str, **kwargs: Any) -> dict[str, Any]: # noqa: A002
|
||||
"""Build a minimal step dict with the given id."""
|
||||
return {"id": id, "type": "command", "command": "speckit.specify", **kwargs}
|
||||
|
||||
|
||||
def _layer(overlay: Overlay, source: str) -> OverlayLayer:
|
||||
"""Build an OverlayLayer for merge_steps."""
|
||||
return OverlayLayer(overlay, source)
|
||||
|
||||
|
||||
class TestFindStep:
|
||||
"""Recursive anchor lookup across nested step lists."""
|
||||
|
||||
def test_find_step_flat(self):
|
||||
steps = [_step("a"), _step("b"), _step("c")]
|
||||
result = find_step(steps, "b")
|
||||
assert result is not None
|
||||
assert result[0] is steps
|
||||
assert result[1] == 1
|
||||
|
||||
def test_find_step_missing(self):
|
||||
steps = [_step("a"), _step("b")]
|
||||
assert find_step(steps, "missing") is None
|
||||
|
||||
def test_find_step_in_then(self):
|
||||
steps = [
|
||||
{
|
||||
"id": "if-1",
|
||||
"type": "if",
|
||||
"condition": "true",
|
||||
"then": [_step("then-a")],
|
||||
"else": [_step("else-b")],
|
||||
},
|
||||
]
|
||||
result = find_step(steps, "then-a")
|
||||
assert result is not None
|
||||
assert result[0] is steps[0]["then"]
|
||||
assert result[1] == 0
|
||||
|
||||
def test_find_step_in_else(self):
|
||||
steps = [
|
||||
{
|
||||
"id": "if-1",
|
||||
"type": "if",
|
||||
"condition": "true",
|
||||
"then": [_step("then-a")],
|
||||
"else": [_step("else-b")],
|
||||
},
|
||||
]
|
||||
result = find_step(steps, "else-b")
|
||||
assert result is not None
|
||||
assert result[0] is steps[0]["else"]
|
||||
assert result[1] == 0
|
||||
|
||||
def test_find_step_in_nested_steps(self):
|
||||
steps = [
|
||||
{
|
||||
"id": "while-1",
|
||||
"type": "while",
|
||||
"condition": "true",
|
||||
"steps": [_step("inner-a"), _step("inner-b")],
|
||||
},
|
||||
]
|
||||
result = find_step(steps, "inner-b")
|
||||
assert result is not None
|
||||
assert result[0] is steps[0]["steps"]
|
||||
assert result[1] == 1
|
||||
|
||||
def test_find_step_in_switch_cases(self):
|
||||
steps = [
|
||||
{
|
||||
"id": "switch-1",
|
||||
"type": "switch",
|
||||
"expression": "{{ inputs.x }}",
|
||||
"cases": {
|
||||
"one": [_step("case-a")],
|
||||
"two": [_step("case-b")],
|
||||
},
|
||||
"default": [_step("default-c")],
|
||||
},
|
||||
]
|
||||
assert find_step(steps, "case-b")[1] == 0
|
||||
assert find_step(steps, "default-c")[1] == 0
|
||||
|
||||
def test_find_step_not_in_fan_out_template(self):
|
||||
steps = [
|
||||
{
|
||||
"id": "fan-1",
|
||||
"type": "fan-out",
|
||||
"items": "{{ inputs.items }}",
|
||||
"step": {"id": "template-x", "type": "command", "command": "echo"},
|
||||
},
|
||||
]
|
||||
assert find_step(steps, "template-x") is None
|
||||
|
||||
|
||||
class TestMergeSteps:
|
||||
"""Composition of multiple overlays in merge order."""
|
||||
|
||||
def test_merge_steps_no_overlays(self):
|
||||
base = [_step("a"), _step("b")]
|
||||
steps, attribution = merge_steps(base, [])
|
||||
assert [s["id"] for s in steps] == ["a", "b"]
|
||||
assert attribution == [ComposedStep("a", "base"), ComposedStep("b", "base")]
|
||||
|
||||
def test_merge_steps_single_overlay(self):
|
||||
base = [_step("a"), _step("b")]
|
||||
overlay = Overlay(
|
||||
id="ov1",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("insert_after", "a", _step("new"))],
|
||||
)
|
||||
steps, attribution = merge_steps(base, [_layer(overlay, "project:ov1")])
|
||||
assert [s["id"] for s in steps] == ["a", "new", "b"]
|
||||
assert attribution == [
|
||||
ComposedStep("a", "base"),
|
||||
ComposedStep("new", "project:ov1"),
|
||||
ComposedStep("b", "base"),
|
||||
]
|
||||
|
||||
def test_merge_steps_higher_priority_wins(self):
|
||||
base = [_step("a")]
|
||||
low = Overlay(
|
||||
id="low",
|
||||
extends="wf",
|
||||
priority=5,
|
||||
edits=[OverlayEdit("insert_after", "a", _step("low-step"))],
|
||||
)
|
||||
high = Overlay(
|
||||
id="high",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("insert_after", "a", _step("high-step"))],
|
||||
)
|
||||
steps, attribution = merge_steps(base, [_layer(low, "project:low"), _layer(high, "project:high")])
|
||||
# low applied first, then high; both insert after 'a', so high-step ends
|
||||
# closer to the anchor (higher priority wins the conflict).
|
||||
assert [s["id"] for s in steps] == ["a", "high-step", "low-step"]
|
||||
assert attribution == [
|
||||
ComposedStep("a", "base"),
|
||||
ComposedStep("high-step", "project:high"),
|
||||
ComposedStep("low-step", "project:low"),
|
||||
]
|
||||
|
||||
def test_merge_steps_replace_wins_over_insert(self):
|
||||
"""Overlays apply to the original tree only; targeting an overlay-introduced step raises."""
|
||||
base = [_step("a")]
|
||||
insert = Overlay(
|
||||
id="insert",
|
||||
extends="wf",
|
||||
priority=5,
|
||||
edits=[OverlayEdit("insert_after", "a", _step("inserted"))],
|
||||
)
|
||||
replace = Overlay(
|
||||
id="replace",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("replace", "inserted", _step("replaced"))],
|
||||
)
|
||||
# "inserted" is not a base step — overlays cannot target each other's steps.
|
||||
with pytest.raises(ValueError, match="Anchor 'inserted' not found"):
|
||||
merge_steps(base, [_layer(insert, "project:insert"), _layer(replace, "project:replace")])
|
||||
|
||||
def test_merge_steps_does_not_mutate_base(self):
|
||||
base = [_step("a")]
|
||||
overlay = Overlay(
|
||||
id="ov1",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("insert_after", "a", _step("new"))],
|
||||
)
|
||||
original = copy.deepcopy(base)
|
||||
merge_steps(base, [_layer(overlay, "project:ov1")])
|
||||
assert base == original
|
||||
|
||||
def test_merge_steps_attribution_uses_source_not_overlay_id(self):
|
||||
base = [_step("a")]
|
||||
overlay = Overlay(
|
||||
id="same-id",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("insert_after", "a", _step("new"))],
|
||||
)
|
||||
steps, attribution = merge_steps(base, [_layer(overlay, "installed:same-id")])
|
||||
assert [s["id"] for s in steps] == ["a", "new"]
|
||||
assert attribution == [
|
||||
ComposedStep("a", "base"),
|
||||
ComposedStep("new", "installed:same-id"),
|
||||
]
|
||||
|
||||
def test_merge_steps_nested_base_attribution(self):
|
||||
base = [
|
||||
{
|
||||
"id": "if-1",
|
||||
"type": "if",
|
||||
"condition": "true",
|
||||
"then": [_step("then-a")],
|
||||
"else": [_step("else-b")],
|
||||
},
|
||||
]
|
||||
steps, attribution = merge_steps(base, [])
|
||||
assert attribution == [
|
||||
ComposedStep("if-1", "base"),
|
||||
ComposedStep("then-a", "base"),
|
||||
ComposedStep("else-b", "base"),
|
||||
]
|
||||
|
||||
def test_merge_steps_higher_replace_wins_lower_replace_same_anchor(self):
|
||||
base = [_step("implement")]
|
||||
low = Overlay(
|
||||
id="low",
|
||||
extends="wf",
|
||||
priority=5,
|
||||
edits=[OverlayEdit("replace", "implement", _step("low-implement"))],
|
||||
)
|
||||
high = Overlay(
|
||||
id="high",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("replace", "implement", _step("high-implement"))],
|
||||
)
|
||||
steps, attribution = merge_steps(base, [_layer(low, "project:low"), _layer(high, "project:high")])
|
||||
assert [s["id"] for s in steps] == ["high-implement"]
|
||||
assert any(
|
||||
composed.step_id == "high-implement" and composed.source == "project:high"
|
||||
for composed in attribution
|
||||
)
|
||||
|
||||
def test_merge_steps_higher_replace_wins_after_lower_remove_same_anchor(self):
|
||||
base = [_step("implement")]
|
||||
low = Overlay(
|
||||
id="low",
|
||||
extends="wf",
|
||||
priority=5,
|
||||
edits=[OverlayEdit("remove", "implement")],
|
||||
)
|
||||
high = Overlay(
|
||||
id="high",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("replace", "implement", _step("high-implement"))],
|
||||
)
|
||||
steps, attribution = merge_steps(base, [_layer(low, "project:low"), _layer(high, "project:high")])
|
||||
assert [s["id"] for s in steps] == ["high-implement"]
|
||||
|
||||
def test_merge_steps_higher_insert_wins_after_lower_remove_same_anchor(self):
|
||||
base = [_step("implement")]
|
||||
low = Overlay(
|
||||
id="low",
|
||||
extends="wf",
|
||||
priority=5,
|
||||
edits=[OverlayEdit("remove", "implement")],
|
||||
)
|
||||
high = Overlay(
|
||||
id="high",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("insert_after", "implement", _step("high-after"))],
|
||||
)
|
||||
steps, attribution = merge_steps(base, [_layer(low, "project:low"), _layer(high, "project:high")])
|
||||
assert [s["id"] for s in steps] == ["implement", "high-after"]
|
||||
assert attribution == [
|
||||
ComposedStep("implement", "base"),
|
||||
ComposedStep("high-after", "project:high"),
|
||||
]
|
||||
|
||||
def test_merge_steps_later_overlay_wins_tie_same_anchor(self):
|
||||
"""When two overlays have the same priority, the one applied later wins."""
|
||||
base = [_step("a")]
|
||||
first = Overlay(
|
||||
id="first",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("replace", "a", _step("first-replace"))],
|
||||
)
|
||||
second = Overlay(
|
||||
id="second",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("replace", "a", _step("second-replace"))],
|
||||
)
|
||||
# Merge order: first applied, then second wins tie.
|
||||
steps, attribution = merge_steps(
|
||||
base,
|
||||
[
|
||||
_layer(first, "overlay:first"),
|
||||
_layer(second, "overlay:second"),
|
||||
],
|
||||
)
|
||||
assert [s["id"] for s in steps] == ["second-replace"]
|
||||
assert any(
|
||||
composed.step_id == "second-replace" and composed.source == "overlay:second"
|
||||
for composed in attribution
|
||||
)
|
||||
|
||||
def test_merge_steps_insert_after_then_replace_same_anchor_id_change(self):
|
||||
"""Inserts must be applied before the winning replace so the anchor still exists.
|
||||
|
||||
Regression: when a replace changes the step ID, applying it before inserts
|
||||
causes ``find_step`` to fail on the now-gone original anchor.
|
||||
"""
|
||||
base = [_step("build")]
|
||||
low = Overlay(
|
||||
id="low",
|
||||
extends="wf",
|
||||
priority=5,
|
||||
edits=[OverlayEdit("insert_after", "build", _step("test"))],
|
||||
)
|
||||
high = Overlay(
|
||||
id="high",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("replace", "build", _step("compile"))],
|
||||
)
|
||||
steps, attribution = merge_steps(
|
||||
base, [_layer(low, "project:low"), _layer(high, "project:high")]
|
||||
)
|
||||
# The insert should land after the original anchor position, then the
|
||||
# anchor is replaced. Final order: ["compile", "test"].
|
||||
assert [s["id"] for s in steps] == ["compile", "test"]
|
||||
assert attribution == [
|
||||
ComposedStep("compile", "project:high"),
|
||||
ComposedStep("test", "project:low"),
|
||||
]
|
||||
|
||||
def test_merge_steps_insert_before_then_replace_same_anchor_id_change(self):
|
||||
"""Same as above but with insert_before — anchor must still be findable."""
|
||||
base = [_step("build")]
|
||||
low = Overlay(
|
||||
id="low",
|
||||
extends="wf",
|
||||
priority=5,
|
||||
edits=[OverlayEdit("insert_before", "build", _step("lint"))],
|
||||
)
|
||||
high = Overlay(
|
||||
id="high",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("replace", "build", _step("compile"))],
|
||||
)
|
||||
steps, attribution = merge_steps(
|
||||
base, [_layer(low, "project:low"), _layer(high, "project:high")]
|
||||
)
|
||||
assert [s["id"] for s in steps] == ["lint", "compile"]
|
||||
assert attribution == [
|
||||
ComposedStep("lint", "project:low"),
|
||||
ComposedStep("compile", "project:high"),
|
||||
]
|
||||
|
||||
def test_merge_steps_unknown_anchor_still_raises(self):
|
||||
base = [_step("a")]
|
||||
overlay = Overlay(
|
||||
id="ov",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("replace", "missing", _step("new"))],
|
||||
)
|
||||
with pytest.raises(ValueError, match="Anchor 'missing' not found"):
|
||||
merge_steps(base, [_layer(overlay, "project:ov")])
|
||||
|
||||
# ── composite step attribution ───────────────────────────────────────
|
||||
|
||||
def test_merge_insert_composite_if_attribution(self):
|
||||
"""Nested then/else children of an inserted 'if' step get the overlay source."""
|
||||
base = [_step("a")]
|
||||
composite = {
|
||||
"id": "if-1",
|
||||
"type": "if",
|
||||
"condition": "true",
|
||||
"then": [_step("then-a")],
|
||||
"else": [_step("else-b")],
|
||||
}
|
||||
overlay = Overlay(
|
||||
id="ov", extends="wf", priority=10,
|
||||
edits=[OverlayEdit("insert_after", "a", composite)],
|
||||
)
|
||||
_steps, attribution = merge_steps(
|
||||
base, [_layer(overlay, "project:ov")]
|
||||
)
|
||||
assert attribution == [
|
||||
ComposedStep("a", "base"),
|
||||
ComposedStep("if-1", "project:ov"),
|
||||
ComposedStep("then-a", "project:ov"),
|
||||
ComposedStep("else-b", "project:ov"),
|
||||
]
|
||||
|
||||
def test_merge_insert_composite_switch_attribution(self):
|
||||
"""Nested cases/default children of an inserted 'switch' step get the overlay source."""
|
||||
base = [_step("a")]
|
||||
composite = {
|
||||
"id": "switch-1",
|
||||
"type": "switch",
|
||||
"expression": "{{inputs.x}}",
|
||||
"cases": {"one": [_step("case-one")], "two": [_step("case-two")]},
|
||||
"default": [_step("default-z")],
|
||||
}
|
||||
overlay = Overlay(
|
||||
id="ov", extends="wf", priority=10,
|
||||
edits=[OverlayEdit("insert_before", "a", composite)],
|
||||
)
|
||||
_steps, attribution = merge_steps(
|
||||
base, [_layer(overlay, "project:ov")]
|
||||
)
|
||||
assert attribution == [
|
||||
ComposedStep("switch-1", "project:ov"),
|
||||
ComposedStep("default-z", "project:ov"),
|
||||
ComposedStep("case-one", "project:ov"),
|
||||
ComposedStep("case-two", "project:ov"),
|
||||
ComposedStep("a", "base"),
|
||||
]
|
||||
|
||||
def test_merge_replace_flat_with_composite_attribution(self):
|
||||
"""Replacing a flat step with a composite step attributes all nested children."""
|
||||
base = [_step("a")]
|
||||
composite = {
|
||||
"id": "if-1",
|
||||
"type": "if",
|
||||
"condition": "true",
|
||||
"then": [_step("inner-x"), _step("inner-y")],
|
||||
}
|
||||
overlay = Overlay(
|
||||
id="ov", extends="wf", priority=10,
|
||||
edits=[OverlayEdit("replace", "a", composite)],
|
||||
)
|
||||
_steps, attribution = merge_steps(
|
||||
base, [_layer(overlay, "project:ov")]
|
||||
)
|
||||
assert attribution == [
|
||||
ComposedStep("if-1", "project:ov"),
|
||||
ComposedStep("inner-x", "project:ov"),
|
||||
ComposedStep("inner-y", "project:ov"),
|
||||
]
|
||||
|
||||
def test_merge_remove_composite_step_cleans_nested_sources(self):
|
||||
"""Removing a composite step also cleans its nested children from sources."""
|
||||
base = [
|
||||
{
|
||||
"id": "if-1",
|
||||
"type": "if",
|
||||
"condition": "true",
|
||||
"then": [_step("then-a")],
|
||||
"else": [_step("else-b")],
|
||||
},
|
||||
_step("a"),
|
||||
]
|
||||
overlay = Overlay(
|
||||
id="ov", extends="wf", priority=10,
|
||||
edits=[OverlayEdit("remove", "if-1")],
|
||||
)
|
||||
steps, attribution = merge_steps(
|
||||
base, [_layer(overlay, "project:ov")]
|
||||
)
|
||||
assert [s["id"] for s in steps] == ["a"]
|
||||
assert attribution == [ComposedStep("a", "base")]
|
||||
|
||||
def test_merge_insert_deeply_nested_composite_attribution(self):
|
||||
"""Deep nesting (if inside while) gets the overlay source at every level."""
|
||||
base = [_step("a")]
|
||||
inner_if = {
|
||||
"id": "inner-if",
|
||||
"type": "if",
|
||||
"condition": "true",
|
||||
"then": [_step("deep-x")],
|
||||
}
|
||||
composite = {
|
||||
"id": "while-1",
|
||||
"type": "while",
|
||||
"condition": "true",
|
||||
"steps": [inner_if],
|
||||
}
|
||||
overlay = Overlay(
|
||||
id="ov", extends="wf", priority=10,
|
||||
edits=[OverlayEdit("insert_after", "a", composite)],
|
||||
)
|
||||
_steps, attribution = merge_steps(
|
||||
base, [_layer(overlay, "project:ov")]
|
||||
)
|
||||
assert attribution == [
|
||||
ComposedStep("a", "base"),
|
||||
ComposedStep("while-1", "project:ov"),
|
||||
ComposedStep("inner-if", "project:ov"),
|
||||
ComposedStep("deep-x", "project:ov"),
|
||||
]
|
||||
|
||||
|
||||
class TestValidateEdits:
|
||||
"""Edit validation against known base step IDs."""
|
||||
|
||||
def test_valid_edits(self):
|
||||
edits = [
|
||||
OverlayEdit("insert_after", "a", _step("new")),
|
||||
OverlayEdit("remove", "b"),
|
||||
]
|
||||
assert validate_edits(edits, {"a", "b"}) == []
|
||||
|
||||
def test_invalid_anchor(self):
|
||||
edits = [OverlayEdit("insert_after", "missing", _step("new"))]
|
||||
errors = validate_edits(edits, {"a"})
|
||||
assert any("missing" in e for e in errors)
|
||||
|
||||
def test_step_id_contains_colon(self):
|
||||
edits = [OverlayEdit("insert_after", "a", _step("bad:id"))]
|
||||
errors = validate_edits(edits, {"a"})
|
||||
assert any("':'" in e for e in errors)
|
||||
|
||||
def test_remove_requires_no_step(self):
|
||||
edits = [OverlayEdit("remove", "a", _step("extra"))]
|
||||
errors = validate_edits(edits, {"a"})
|
||||
assert len(errors) > 0
|
||||
|
||||
|
||||
class TestMergeStepsAncestorConflicts:
|
||||
"""merge_steps raises when two targeted anchors are in a parent/descendant relationship."""
|
||||
|
||||
def _if_step(self, parent_id: str, child_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": parent_id,
|
||||
"type": "if",
|
||||
"condition": "true",
|
||||
"then": [_step(child_id)],
|
||||
}
|
||||
|
||||
def test_remove_parent_and_insert_after_child_raises(self):
|
||||
"""Removing a parent while inserting after its nested child is an anchor conflict."""
|
||||
parent_id = "if-step"
|
||||
child_id = "then-child"
|
||||
base = [self._if_step(parent_id, child_id)]
|
||||
overlay = Overlay(
|
||||
id="ov",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[
|
||||
OverlayEdit("remove", parent_id),
|
||||
OverlayEdit("insert_after", child_id, _step("new-step")),
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="ancestor"):
|
||||
merge_steps(base, [_layer(overlay, "project:ov")])
|
||||
|
||||
def test_replace_parent_and_remove_child_raises(self):
|
||||
"""Replacing a parent while also removing a nested child is an anchor conflict."""
|
||||
parent_id = "if-step"
|
||||
child_id = "then-child"
|
||||
base = [self._if_step(parent_id, child_id)]
|
||||
overlay = Overlay(
|
||||
id="ov",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[
|
||||
OverlayEdit("replace", parent_id, _step("new-parent")),
|
||||
OverlayEdit("remove", child_id),
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="ancestor"):
|
||||
merge_steps(base, [_layer(overlay, "project:ov")])
|
||||
|
||||
def test_conflict_across_multiple_overlays_raises(self):
|
||||
"""Conflict is detected even when conflicting anchors come from different overlays."""
|
||||
parent_id = "if-step"
|
||||
child_id = "then-child"
|
||||
base = [self._if_step(parent_id, child_id)]
|
||||
overlay_a = Overlay(
|
||||
id="ov-a",
|
||||
extends="wf",
|
||||
priority=5,
|
||||
edits=[OverlayEdit("remove", parent_id)],
|
||||
)
|
||||
overlay_b = Overlay(
|
||||
id="ov-b",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("insert_after", child_id, _step("new-step"))],
|
||||
)
|
||||
with pytest.raises(ValueError, match="ancestor"):
|
||||
merge_steps(
|
||||
base,
|
||||
[_layer(overlay_a, "project:ov-a"), _layer(overlay_b, "project:ov-b")],
|
||||
)
|
||||
|
||||
def test_sibling_anchors_not_conflicting(self):
|
||||
"""Anchors in sibling branches (not ancestor/descendant) are allowed."""
|
||||
base = [
|
||||
{
|
||||
"id": "if-step",
|
||||
"type": "if",
|
||||
"condition": "true",
|
||||
"then": [_step("then-child")],
|
||||
"else": [_step("else-child")],
|
||||
}
|
||||
]
|
||||
overlay = Overlay(
|
||||
id="ov",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[
|
||||
OverlayEdit("insert_after", "then-child", _step("after-then")),
|
||||
OverlayEdit("insert_after", "else-child", _step("after-else")),
|
||||
],
|
||||
)
|
||||
# Should not raise — the two anchors are siblings, not ancestor/descendant.
|
||||
steps, _ = merge_steps(base, [_layer(overlay, "project:ov")])
|
||||
step_ids = [s.get("id") for s in steps[0]["then"]] + [s.get("id") for s in steps[0]["else"]]
|
||||
assert "after-then" in step_ids
|
||||
assert "after-else" in step_ids
|
||||
|
||||
def test_single_anchor_not_conflicting(self):
|
||||
"""A single anchor is never in conflict with itself."""
|
||||
parent_id = "if-step"
|
||||
child_id = "then-child"
|
||||
base = [self._if_step(parent_id, child_id)]
|
||||
overlay = Overlay(
|
||||
id="ov",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[OverlayEdit("remove", parent_id)],
|
||||
)
|
||||
steps, _ = merge_steps(base, [_layer(overlay, "project:ov")])
|
||||
assert steps == []
|
||||
|
||||
def test_child_not_targeted_no_conflict(self):
|
||||
"""Targeting a parent alone (child not in any edit) is allowed."""
|
||||
parent_id = "if-step"
|
||||
child_id = "then-child"
|
||||
base = [self._if_step(parent_id, child_id), _step("other")]
|
||||
overlay = Overlay(
|
||||
id="ov",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[
|
||||
OverlayEdit("remove", parent_id),
|
||||
OverlayEdit("insert_after", "other", _step("new-step")),
|
||||
],
|
||||
)
|
||||
# "other" is not inside "if-step", so no ancestor conflict.
|
||||
steps, _ = merge_steps(base, [_layer(overlay, "project:ov")])
|
||||
assert [s["id"] for s in steps] == ["other", "new-step"]
|
||||
|
||||
def test_insert_only_on_ancestor_and_descendant_not_conflicting(self):
|
||||
"""insert_after on both a parent and its nested child is valid and order-independent."""
|
||||
parent_id = "if-step"
|
||||
child_id = "then-child"
|
||||
base = [self._if_step(parent_id, child_id)]
|
||||
overlay = Overlay(
|
||||
id="ov",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[
|
||||
OverlayEdit("insert_after", parent_id, _step("after-parent")),
|
||||
OverlayEdit("insert_after", child_id, _step("after-child")),
|
||||
],
|
||||
)
|
||||
# Should not raise — inserts leave the ancestor intact.
|
||||
steps, attribution = merge_steps(base, [_layer(overlay, "project:ov")])
|
||||
# "after-parent" is inserted at the top level after the if-step.
|
||||
assert [s["id"] for s in steps] == [parent_id, "after-parent"]
|
||||
# "after-child" is inserted inside the then list.
|
||||
then_ids = [s["id"] for s in steps[0]["then"]]
|
||||
assert then_ids == [child_id, "after-child"]
|
||||
|
||||
|
||||
class TestMergeStepsIdCollision:
|
||||
"""merge_steps is deterministic when a replacement reuses a base step ID."""
|
||||
|
||||
def test_replace_with_reused_id_does_not_affect_original(self):
|
||||
"""Replacing A with new_step(id=B) must not interfere with editing original B.
|
||||
|
||||
Before the fix, the remove-B anchor group would find the replacement step
|
||||
(which now has id='b') instead of the original 'b' step, producing a
|
||||
different result depending on dict iteration order.
|
||||
"""
|
||||
base = [_step("a"), _step("b"), _step("c")]
|
||||
overlay = Overlay(
|
||||
id="ov",
|
||||
extends="wf",
|
||||
priority=10,
|
||||
edits=[
|
||||
# Replace "a" with a new step that reuses id "b".
|
||||
OverlayEdit("replace", "a", {**_step("b"), "command": "speckit.replaced"}),
|
||||
# Remove the original "b".
|
||||
OverlayEdit("remove", "b"),
|
||||
],
|
||||
)
|
||||
steps, attribution = merge_steps(base, [_layer(overlay, "project:ov")])
|
||||
# The original "b" is removed; the replacement (also id="b") survives.
|
||||
# "c" is untouched.
|
||||
assert len(steps) == 2
|
||||
remaining_ids = [s["id"] for s in steps]
|
||||
assert remaining_ids == ["b", "c"]
|
||||
# The surviving "b" step is the replacement (has the custom command).
|
||||
assert steps[0]["command"] == "speckit.replaced"
|
||||
# Attribution for the surviving replacement "b" must not be "unknown".
|
||||
# Previously, removing original "b" would pop sources["b"], erasing the
|
||||
# attribution recorded for the replacement step (regression guard for the
|
||||
# _remove_sources_recursively-in-remove-branch bug).
|
||||
sources = {cs.step_id: cs.source for cs in attribution}
|
||||
assert sources.get("b") == "project:ov", (
|
||||
f"expected 'project:ov' but got {sources.get('b')!r}"
|
||||
)
|
||||
277
tests/workflows/test_overlay_schema.py
Normal file
277
tests/workflows/test_overlay_schema.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""Tests for overlay YAML schema normalization, especially shorthand edits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.workflows.overlays.schema import (
|
||||
OverlayEdit,
|
||||
validate_overlay_yaml,
|
||||
)
|
||||
|
||||
|
||||
class TestShorthandEdits:
|
||||
"""Requirements-compliant shorthand edit format."""
|
||||
|
||||
def test_shorthand_insert_after(self):
|
||||
overlay, errors = validate_overlay_yaml(
|
||||
{
|
||||
"id": "lint",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"insert_after": "implement",
|
||||
"step": {"id": "lint", "type": "shell", "command": "npm run lint"},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
assert not errors, errors
|
||||
assert overlay is not None
|
||||
assert overlay.edits == [
|
||||
OverlayEdit("insert_after", "implement", {"id": "lint", "type": "shell", "command": "npm run lint"})
|
||||
]
|
||||
|
||||
def test_shorthand_insert_before(self):
|
||||
overlay, errors = validate_overlay_yaml(
|
||||
{
|
||||
"id": "ov",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"insert_before": "a",
|
||||
"step": {"id": "b", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
assert not errors, errors
|
||||
assert overlay is not None
|
||||
assert overlay.edits == [
|
||||
OverlayEdit("insert_before", "a", {"id": "b", "type": "command", "command": "echo"})
|
||||
]
|
||||
|
||||
def test_shorthand_replace(self):
|
||||
overlay, errors = validate_overlay_yaml(
|
||||
{
|
||||
"id": "ov",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"replace": "a",
|
||||
"step": {"id": "a", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
assert not errors, errors
|
||||
assert overlay is not None
|
||||
assert overlay.edits == [
|
||||
OverlayEdit("replace", "a", {"id": "a", "type": "command", "command": "echo"})
|
||||
]
|
||||
|
||||
def test_shorthand_remove(self):
|
||||
overlay, errors = validate_overlay_yaml(
|
||||
{
|
||||
"id": "ov",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [{"remove": "a"}],
|
||||
}
|
||||
)
|
||||
assert not errors, errors
|
||||
assert overlay is not None
|
||||
assert overlay.edits == [OverlayEdit("remove", "a")]
|
||||
|
||||
def test_explicit_operation_format_still_valid(self):
|
||||
overlay, errors = validate_overlay_yaml(
|
||||
{
|
||||
"id": "ov",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "b", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
assert not errors, errors
|
||||
assert overlay is not None
|
||||
assert overlay.edits == [
|
||||
OverlayEdit("insert_after", "a", {"id": "b", "type": "command", "command": "echo"})
|
||||
]
|
||||
|
||||
def test_multiple_operation_fields_rejected(self):
|
||||
overlay, errors = validate_overlay_yaml(
|
||||
{
|
||||
"id": "ov",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"insert_after": "a",
|
||||
"remove": "a",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
assert overlay is None
|
||||
assert any("multiple" in e.lower() for e in errors), errors
|
||||
|
||||
def test_invalid_operation_field_rejected(self):
|
||||
overlay, errors = validate_overlay_yaml(
|
||||
{
|
||||
"id": "ov",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [{"destroy": "a"}],
|
||||
}
|
||||
)
|
||||
assert overlay is None
|
||||
assert any("operation" in e.lower() for e in errors), errors
|
||||
|
||||
def test_shorthand_and_explicit_mixed_list(self):
|
||||
overlay, errors = validate_overlay_yaml(
|
||||
{
|
||||
"id": "ov",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{"insert_after": "a", "step": {"id": "b", "type": "command", "command": "echo"}},
|
||||
{
|
||||
"operation": "remove",
|
||||
"anchor": "c",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert not errors, errors
|
||||
assert overlay is not None
|
||||
assert overlay.edits == [
|
||||
OverlayEdit("insert_after", "a", {"id": "b", "type": "command", "command": "echo"}),
|
||||
OverlayEdit("remove", "c"),
|
||||
]
|
||||
|
||||
def test_shorthand_remove_must_not_include_step(self):
|
||||
overlay, errors = validate_overlay_yaml(
|
||||
{
|
||||
"id": "ov",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"remove": "a",
|
||||
"step": {"id": "b", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
assert overlay is None
|
||||
assert any("remove" in e.lower() and "step" in e.lower() for e in errors), errors
|
||||
|
||||
|
||||
class TestOverlayIdValidation:
|
||||
"""Overlay and workflow IDs must be safe path segments."""
|
||||
|
||||
@pytest.mark.parametrize("overlay_id", ["../ov", "a/b", "a\\\\b", ".", "..", ""])
|
||||
def test_invalid_overlay_id_rejected(self, overlay_id):
|
||||
overlay, errors = validate_overlay_yaml(
|
||||
{
|
||||
"id": overlay_id,
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [{"remove": "a"}],
|
||||
}
|
||||
)
|
||||
assert overlay is None
|
||||
assert any("id" in e.lower() for e in errors), errors
|
||||
|
||||
|
||||
class TestOverlayPriorityNormalization:
|
||||
"""Stored overlay priorities match preset normalization semantics."""
|
||||
|
||||
@pytest.mark.parametrize("priority", [None, True, "invalid", 0])
|
||||
def test_invalid_or_missing_priority_defaults_to_ten(self, priority):
|
||||
data = {
|
||||
"id": "ov",
|
||||
"extends": "wf",
|
||||
"edits": [{"remove": "a"}],
|
||||
}
|
||||
if priority is not None:
|
||||
data["priority"] = priority
|
||||
|
||||
overlay, errors = validate_overlay_yaml(data)
|
||||
|
||||
assert errors == []
|
||||
assert overlay is not None
|
||||
assert overlay.priority == 10
|
||||
|
||||
@pytest.mark.parametrize("extends", ["../wf", "a/b", "a\\\\b", ".", "..", ""])
|
||||
def test_invalid_extends_rejected(self, extends):
|
||||
overlay, errors = validate_overlay_yaml(
|
||||
{
|
||||
"id": "ov",
|
||||
"extends": extends,
|
||||
"priority": 10,
|
||||
"edits": [{"remove": "a"}],
|
||||
}
|
||||
)
|
||||
assert overlay is None
|
||||
assert any("extends" in e.lower() for e in errors), errors
|
||||
|
||||
@pytest.mark.parametrize("extends", ["overlays", "runs", "steps"])
|
||||
def test_reserved_workflow_id_rejected(self, extends):
|
||||
overlay, errors = validate_overlay_yaml(
|
||||
{
|
||||
"id": "ov",
|
||||
"extends": extends,
|
||||
"priority": 10,
|
||||
"edits": [{"remove": "a"}],
|
||||
}
|
||||
)
|
||||
assert overlay is None
|
||||
assert any("reserved" in error.lower() for error in errors), errors
|
||||
|
||||
def test_valid_dashed_id_accepted(self):
|
||||
overlay, errors = validate_overlay_yaml(
|
||||
{
|
||||
"id": "my-overlay",
|
||||
"extends": "my-workflow",
|
||||
"priority": 10,
|
||||
"edits": [{"remove": "a"}],
|
||||
}
|
||||
)
|
||||
assert not errors, errors
|
||||
assert overlay is not None
|
||||
|
||||
def test_validate_safe_id_rejects_trailing_newline(self):
|
||||
"""A trailing newline must not pass ID validation (fullmatch guard)."""
|
||||
overlay, errors = validate_overlay_yaml(
|
||||
{
|
||||
"id": "overlay\n",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [{"remove": "a"}],
|
||||
}
|
||||
)
|
||||
assert overlay is None
|
||||
assert any("id" in e.lower() for e in errors), errors
|
||||
|
||||
def test_validate_safe_id_rejects_embedded_newline(self):
|
||||
"""An embedded newline must not pass ID validation."""
|
||||
overlay, errors = validate_overlay_yaml(
|
||||
{
|
||||
"id": "ov\nerlay",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [{"remove": "a"}],
|
||||
}
|
||||
)
|
||||
assert overlay is None
|
||||
assert any("id" in e.lower() for e in errors), errors
|
||||
321
tests/workflows/test_overlay_security.py
Normal file
321
tests/workflows/test_overlay_security.py
Normal file
@@ -0,0 +1,321 @@
|
||||
"""Security tests for workflow overlay path handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(tmp_path):
|
||||
"""Create a mock spec-kit project with ``.specify/workflows/`` directory."""
|
||||
workflows_dir = tmp_path / ".specify" / "workflows"
|
||||
workflows_dir.mkdir(parents=True, exist_ok=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _write_workflow(project_root: Path, workflow_id: str, data: dict) -> Path:
|
||||
wf_dir = project_root / ".specify" / "workflows" / workflow_id
|
||||
wf_dir.mkdir(parents=True, exist_ok=True)
|
||||
wf_path = wf_dir / "workflow.yml"
|
||||
wf_path.write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
return wf_path
|
||||
|
||||
|
||||
def _write_overlay(project_root: Path, workflow_id: str, overlay_id: str, data: dict) -> Path:
|
||||
ov_dir = project_root / ".specify" / "workflows" / "overlays" / workflow_id
|
||||
ov_dir.mkdir(parents=True, exist_ok=True)
|
||||
ov_path = ov_dir / f"{overlay_id}.yml"
|
||||
ov_path.write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
return ov_path
|
||||
|
||||
|
||||
class TestOverlayPathTraversal:
|
||||
"""Overlay CLI must stay inside the overlay directory."""
|
||||
|
||||
def test_overlay_add_rejects_traversal_in_workflow_id(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
overlay_file = project_dir / "overlay.yml"
|
||||
overlay_file.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "../wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["workflow", "overlay", "add", str(overlay_file), "--priority", "5"]
|
||||
)
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "invalid" in result.output.lower() or "traversal" in result.output.lower()
|
||||
|
||||
def test_overlay_add_rejects_traversal_in_overlay_id(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
overlay_file = project_dir / "overlay.yml"
|
||||
overlay_file.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"id": "../../ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["workflow", "overlay", "add", str(overlay_file), "--priority", "5"]
|
||||
)
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "invalid" in result.output.lower() or "traversal" in result.output.lower()
|
||||
|
||||
def test_overlay_remove_cannot_escape_overlays_dir(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
# Create a base workflow file that would be the traversal target.
|
||||
target = project_dir / ".specify" / "workflows" / "wf" / "workflow.yml"
|
||||
assert target.is_file()
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["workflow", "overlay", "remove", "wf", "../wf/workflow"]
|
||||
)
|
||||
assert result.exit_code != 0, result.output
|
||||
assert target.is_file()
|
||||
assert "Invalid" in result.output or "traversal" in result.output.lower()
|
||||
|
||||
def test_overlay_remove_rejects_symlink(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
overlay_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf"
|
||||
real_file = overlay_dir / "ov1.yml"
|
||||
symlink_file = overlay_dir / "symlink.yml"
|
||||
symlink_file.symlink_to(real_file)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "remove", "wf", "symlink"])
|
||||
assert result.exit_code != 0, result.output
|
||||
assert real_file.is_file()
|
||||
assert "symlink" in result.output.lower() or "Invalid" in result.output
|
||||
|
||||
def test_overlay_add_rejects_symlinked_target_file(self, project_dir, monkeypatch):
|
||||
"""overlay add must not overwrite through a symlinked overlay file target."""
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
|
||||
overlay_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf"
|
||||
overlay_dir.mkdir(parents=True, exist_ok=True)
|
||||
real_file = overlay_dir / "other.yml"
|
||||
real_file.write_text("sentinel\n", encoding="utf-8")
|
||||
(overlay_dir / "ov1.yml").symlink_to(real_file)
|
||||
|
||||
overlay_file = project_dir / "overlay.yml"
|
||||
overlay_file.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "add", str(overlay_file)])
|
||||
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "symlinked path" in result.output.lower()
|
||||
assert real_file.read_text(encoding="utf-8") == "sentinel\n"
|
||||
|
||||
@pytest.mark.parametrize("workflow_id", ["overlays", "runs", "steps"])
|
||||
def test_overlay_operations_reject_reserved_workflow_id(
|
||||
self, project_dir, monkeypatch, workflow_id
|
||||
):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
result = runner.invoke(app, ["workflow", "overlay", "list", workflow_id])
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "Invalid" in result.output or "reserved" in result.output.lower()
|
||||
|
||||
def test_overlay_set_priority_rejects_traversal(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
result = runner.invoke(
|
||||
app, ["workflow", "overlay", "set-priority", "wf", "../other", "10"]
|
||||
)
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "invalid" in result.output.lower() or "traversal" in result.output.lower()
|
||||
|
||||
def test_overlay_enable_rejects_traversal(self, project_dir, monkeypatch):
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
result = runner.invoke(app, ["workflow", "overlay", "enable", "wf", "../other"])
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "invalid" in result.output.lower() or "traversal" in result.output.lower()
|
||||
|
||||
def test_overlay_rejects_symlinked_overlays_dir(self, project_dir, monkeypatch, tmp_path):
|
||||
"""Overlay commands must reject a symlinked .specify/workflows/overlays directory."""
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
|
||||
# Create a symlinked overlays directory pointing outside the project
|
||||
outside_dir = tmp_path / "outside"
|
||||
outside_dir.mkdir()
|
||||
overlays_dir = project_dir / ".specify" / "workflows" / "overlays"
|
||||
overlays_dir.symlink_to(outside_dir)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "list", "wf"])
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "symlink" in result.output.lower()
|
||||
|
||||
def test_overlay_list_rejects_symlinked_per_workflow_dir(self, project_dir, monkeypatch, tmp_path):
|
||||
"""Overlay list must reject a symlinked per-workflow overlay directory."""
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
|
||||
# Create a real overlay directory outside the project.
|
||||
outside_dir = tmp_path / "outside_wf"
|
||||
outside_dir.mkdir()
|
||||
outside_dir.joinpath("evil.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"id": "evil",
|
||||
"extends": "wf",
|
||||
"priority": 100,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "evil-step", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Symlink the per-workflow overlay directory to the outside location.
|
||||
overlays_root = project_dir / ".specify" / "workflows" / "overlays"
|
||||
overlays_root.mkdir(parents=True, exist_ok=True)
|
||||
symlink_dir = overlays_root / "wf"
|
||||
symlink_dir.symlink_to(outside_dir)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "list", "wf"])
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "symlink" in result.output.lower()
|
||||
|
||||
def test_overlay_list_reports_invalid_yaml_cleanly(self, project_dir, monkeypatch):
|
||||
"""Overlay list should surface malformed overlay YAML as a clean user error."""
|
||||
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
|
||||
_write_workflow(
|
||||
project_dir,
|
||||
"wf",
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "echo"}],
|
||||
},
|
||||
)
|
||||
overlay_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf"
|
||||
overlay_dir.mkdir(parents=True, exist_ok=True)
|
||||
(overlay_dir / "broken.yml").write_text("id: broken\nextends: wf\npriority: [\n", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(app, ["workflow", "overlay", "list", "wf"])
|
||||
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "Invalid YAML" in result.output
|
||||
567
tests/workflows/test_resolver_integration.py
Normal file
567
tests/workflows/test_resolver_integration.py
Normal file
@@ -0,0 +1,567 @@
|
||||
"""Integration tests for the WorkflowResolver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
from specify_cli.workflows.overlays import WorkflowResolver
|
||||
from specify_cli.workflows.overlays.merge import ComposedStep
|
||||
|
||||
|
||||
def _write_workflow(project_root: Path, workflow_id: str, data: dict) -> Path:
|
||||
wf_dir = project_root / ".specify" / "workflows" / workflow_id
|
||||
wf_dir.mkdir(parents=True, exist_ok=True)
|
||||
wf_path = wf_dir / "workflow.yml"
|
||||
wf_path.write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
return wf_path
|
||||
|
||||
|
||||
def _write_overlay(project_root: Path, workflow_id: str, overlay_id: str, data: dict) -> Path:
|
||||
ov_dir = project_root / ".specify" / "workflows" / "overlays" / workflow_id
|
||||
ov_dir.mkdir(parents=True, exist_ok=True)
|
||||
ov_path = ov_dir / f"{overlay_id}.yml"
|
||||
ov_path.write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
return ov_path
|
||||
|
||||
|
||||
class TestWorkflowResolver:
|
||||
"""End-to-end resolution of base workflows plus overlays."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"workflow_id",
|
||||
[
|
||||
"../outside",
|
||||
"nested/workflow",
|
||||
"wf\n",
|
||||
"overlays",
|
||||
"runs",
|
||||
"steps",
|
||||
],
|
||||
)
|
||||
def test_rejects_unsafe_id_before_collecting_sources(
|
||||
self, project_dir, workflow_id
|
||||
):
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
|
||||
class UnexpectedSource:
|
||||
def collect(self, _workflow_id):
|
||||
pytest.fail("source collection must not run for an unsafe workflow ID")
|
||||
|
||||
resolver._sources = [UnexpectedSource()]
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid workflow ID"):
|
||||
resolver.resolve(workflow_id)
|
||||
|
||||
def test_rejects_absolute_id_before_collecting_sources(
|
||||
self, project_dir, tmp_path
|
||||
):
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
outside = tmp_path / "outside"
|
||||
|
||||
class UnexpectedSource:
|
||||
def collect(self, _workflow_id):
|
||||
pytest.fail("source collection must not run for an absolute workflow ID")
|
||||
|
||||
resolver._sources = [UnexpectedSource()]
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid workflow ID"):
|
||||
resolver.resolve(str(outside))
|
||||
|
||||
def test_resolve_without_overlays(self, project_dir):
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
|
||||
}
|
||||
_write_workflow(project_dir, "wf", data)
|
||||
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
definition = resolver.resolve("wf")
|
||||
assert isinstance(definition, WorkflowDefinition)
|
||||
assert definition.id == "wf"
|
||||
assert [s["id"] for s in definition.steps] == ["a"]
|
||||
|
||||
def test_resolve_with_project_overlay_insert(self, project_dir):
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [
|
||||
{"id": "a", "type": "command", "command": "speckit.specify"},
|
||||
{"id": "b", "type": "command", "command": "speckit.specify"},
|
||||
],
|
||||
}
|
||||
_write_workflow(project_dir, "wf", data)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "speckit.plan"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
definition = resolver.resolve("wf")
|
||||
assert [s["id"] for s in definition.steps] == ["a", "new", "b"]
|
||||
|
||||
def test_resolve_lower_priority_wins(self, project_dir):
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
|
||||
}
|
||||
_write_workflow(project_dir, "wf", data)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"low",
|
||||
{
|
||||
"id": "low",
|
||||
"extends": "wf",
|
||||
"priority": 5,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "low-step", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"high",
|
||||
{
|
||||
"id": "high",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "high-step", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
definition = resolver.resolve("wf")
|
||||
# Lower priority is applied later; both insert_after 'a', so low-step
|
||||
# ends up closer to the anchor and wins the conflict.
|
||||
assert [s["id"] for s in definition.steps] == ["a", "low-step", "high-step"]
|
||||
|
||||
def test_resolve_with_layers_returns_attribution(self, project_dir):
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
|
||||
}
|
||||
_write_workflow(project_dir, "wf", data)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
definition, layers, attribution = resolver.resolve_with_layers("wf")
|
||||
assert [s["id"] for s in definition.steps] == ["a", "new"]
|
||||
assert any(layer.tier == "base" for layer in layers)
|
||||
assert attribution == [ComposedStep("a", "base"), ComposedStep("new", "project:ov1")]
|
||||
|
||||
def test_resolve_attribution_for_nested_base_steps(self, project_dir):
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [
|
||||
{
|
||||
"id": "if-1",
|
||||
"type": "if",
|
||||
"condition": "true",
|
||||
"then": [{"id": "then-a", "type": "command", "command": "echo"}],
|
||||
"else": [{"id": "else-b", "type": "command", "command": "echo"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
_write_workflow(project_dir, "wf", data)
|
||||
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
definition, _layers, attribution = resolver.resolve_with_layers("wf")
|
||||
assert [s["id"] for s in definition.steps] == ["if-1"]
|
||||
sources = {c.step_id: c.source for c in attribution}
|
||||
assert sources["if-1"] == "base"
|
||||
assert sources["then-a"] == "base"
|
||||
assert sources["else-b"] == "base"
|
||||
|
||||
def test_resolve_invalid_project_overlay_fails(self, project_dir):
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
|
||||
}
|
||||
_write_workflow(project_dir, "wf", data)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"broken",
|
||||
{
|
||||
"id": "broken",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": "not-a-list",
|
||||
},
|
||||
)
|
||||
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
with pytest.raises(ValueError):
|
||||
resolver.resolve("wf")
|
||||
|
||||
def test_resolve_disabled_overlay_is_skipped(self, project_dir):
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
|
||||
}
|
||||
_write_workflow(project_dir, "wf", data)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"disabled",
|
||||
{
|
||||
"id": "disabled",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"enabled": False,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
definition = resolver.resolve("wf")
|
||||
assert [s["id"] for s in definition.steps] == ["a"]
|
||||
|
||||
def test_collect_all_layers_can_include_disabled_overlay_for_listing(self, project_dir):
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
|
||||
}
|
||||
_write_workflow(project_dir, "wf", data)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"disabled",
|
||||
{
|
||||
"id": "disabled",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"enabled": False,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
default_layers = resolver.collect_all_layers("wf")
|
||||
listed_layers = resolver.collect_all_layers("wf", include_disabled=True)
|
||||
|
||||
assert [layer.source for layer in default_layers] == ["base"]
|
||||
assert [layer.source for layer in listed_layers] == ["project:disabled", "base"]
|
||||
|
||||
def test_resolve_invalid_anchor_raises(self, project_dir):
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
|
||||
}
|
||||
_write_workflow(project_dir, "wf", data)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "missing",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
with pytest.raises(ValueError, match="anchor 'missing' does not match any base step id"):
|
||||
resolver.resolve("wf")
|
||||
|
||||
def test_resolve_missing_workflow(self, project_dir):
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
with pytest.raises(FileNotFoundError, match="Workflow not found"):
|
||||
resolver.resolve("missing")
|
||||
|
||||
def test_resolve_returns_composed_result_for_caller_validation(self, project_dir):
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
|
||||
}
|
||||
_write_workflow(project_dir, "wf", data)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "replace",
|
||||
"anchor": "a",
|
||||
"step": {"id": "a", "type": "invalid-type", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
definition = resolver.resolve("wf")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("invalid-type" in err for err in errors)
|
||||
|
||||
def test_resolve_rejects_symlinked_project_overlay_dir(self, project_dir, tmp_path):
|
||||
"""ProjectOverlaySource must reject a symlinked per-workflow overlay directory."""
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
|
||||
}
|
||||
_write_workflow(project_dir, "wf", data)
|
||||
|
||||
# Create a real overlay directory outside the project with a malicious overlay.
|
||||
outside_dir = tmp_path / "outside_overlays" / "wf"
|
||||
outside_dir.mkdir(parents=True, exist_ok=True)
|
||||
outside_dir.joinpath("evil.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"id": "evil",
|
||||
"extends": "wf",
|
||||
"priority": 100,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "evil-step", "type": "command", "command": "rm -rf /"},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Symlink the per-workflow overlay directory to the outside location.
|
||||
overlays_root = project_dir / ".specify" / "workflows" / "overlays"
|
||||
overlays_root.mkdir(parents=True, exist_ok=True)
|
||||
symlink_dir = overlays_root / "wf"
|
||||
symlink_dir.symlink_to(outside_dir)
|
||||
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
with pytest.raises(ValueError, match="Symlinked overlay directories are not allowed"):
|
||||
resolver.resolve("wf")
|
||||
|
||||
def test_resolve_rejects_symlinked_overlay_root(self, project_dir, tmp_path):
|
||||
"""ProjectOverlaySource must reject a symlinked overlay root too."""
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
|
||||
}
|
||||
_write_workflow(project_dir, "wf", data)
|
||||
|
||||
outside_root = tmp_path / "outside-overlays-root"
|
||||
outside_root.mkdir(parents=True, exist_ok=True)
|
||||
workflow_dir = outside_root / "wf"
|
||||
workflow_dir.mkdir()
|
||||
workflow_dir.joinpath("evil.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"id": "evil",
|
||||
"extends": "wf",
|
||||
"priority": 100,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "evil-step", "type": "command", "command": "rm -rf /"},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
overlays_root = project_dir / ".specify" / "workflows" / "overlays"
|
||||
overlays_root.symlink_to(outside_root, target_is_directory=True)
|
||||
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
with pytest.raises(ValueError, match="Symlinked overlay directories are not allowed"):
|
||||
resolver.resolve("wf")
|
||||
|
||||
def test_resolve_reports_invalid_overlay_yaml_cleanly(self, project_dir):
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
|
||||
}
|
||||
_write_workflow(project_dir, "wf", data)
|
||||
overlay_dir = project_dir / ".specify" / "workflows" / "overlays" / "wf"
|
||||
overlay_dir.mkdir(parents=True, exist_ok=True)
|
||||
(overlay_dir / "broken.yml").write_text("id: broken\nextends: wf\npriority: [\n", encoding="utf-8")
|
||||
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
with pytest.raises(ValueError, match="Invalid YAML"):
|
||||
resolver.resolve("wf")
|
||||
|
||||
def test_resolve_attribution_for_inserted_composite_step(self, project_dir):
|
||||
"""Inserted composite steps must attribute nested children to the overlay source."""
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
|
||||
}
|
||||
_write_workflow(project_dir, "wf", data)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {
|
||||
"id": "if-1",
|
||||
"type": "if",
|
||||
"condition": "true",
|
||||
"then": [{"id": "then-x", "type": "command", "command": "echo"}],
|
||||
"else": [{"id": "else-y", "type": "command", "command": "echo"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
resolver = WorkflowResolver(project_dir)
|
||||
_definition, _layers, attribution = resolver.resolve_with_layers("wf")
|
||||
sources = {c.step_id: c.source for c in attribution}
|
||||
assert sources["a"] == "base"
|
||||
assert sources["if-1"] == "project:ov1"
|
||||
assert sources["then-x"] == "project:ov1"
|
||||
assert sources["else-y"] == "project:ov1"
|
||||
|
||||
def test_engine_load_workflow_uses_resolver(self, project_dir):
|
||||
from specify_cli.workflows.engine import WorkflowEngine
|
||||
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {"id": "wf", "name": "WF", "version": "1.0.0"},
|
||||
"steps": [{"id": "a", "type": "command", "command": "speckit.specify"}],
|
||||
}
|
||||
_write_workflow(project_dir, "wf", data)
|
||||
_write_overlay(
|
||||
project_dir,
|
||||
"wf",
|
||||
"ov1",
|
||||
{
|
||||
"id": "ov1",
|
||||
"extends": "wf",
|
||||
"priority": 10,
|
||||
"edits": [
|
||||
{
|
||||
"operation": "insert_after",
|
||||
"anchor": "a",
|
||||
"step": {"id": "new", "type": "command", "command": "echo"},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
engine = WorkflowEngine(project_dir)
|
||||
definition = engine.load_workflow("wf")
|
||||
assert [s["id"] for s in definition.steps] == ["a", "new"]
|
||||
|
||||
def test_engine_rejects_traversal_without_legacy_path_fallback(
|
||||
self, project_dir
|
||||
):
|
||||
from specify_cli.workflows.engine import WorkflowEngine
|
||||
|
||||
outside = project_dir / ".specify" / "outside"
|
||||
outside.mkdir(parents=True)
|
||||
(outside / "workflow.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": "outside",
|
||||
"name": "Outside",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "external",
|
||||
"type": "command",
|
||||
"command": "echo",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
engine = WorkflowEngine(project_dir)
|
||||
with pytest.raises(ValueError, match="Invalid workflow ID"):
|
||||
engine.load_workflow("../outside")
|
||||
Reference in New Issue
Block a user