main
579 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b41058b5e8 |
chore(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.1 (#4244)
* chore(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.1 Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 9.0.0 to 10.0.1. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/c771a70e6277c0a99b617c7a806ffedaca235ff9...20cfd1bf945f4377ade1205e4dbc17946fc9a30d) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> * fix(workflows): align setup-uv generated sources Update the agentic workflow sources, action cache, generated metadata, and regression expectation for setup-uv v10.0.1. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 394a7929-b17c-470f-a3e6-b5863f9c9d40 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Copilot-Session: 394a7929-b17c-470f-a3e6-b5863f9c9d40 |
||
|
|
ca5cd0c0dc |
fix(workflows): require a 'cases' block on switch steps (#4144)
`SwitchStep.validate` requires `expression` and type-checks `cases`, but
never checks that `cases` is PRESENT. It is the only control-flow step whose
branch payload is optional:
if -> requires 'then'
fan-out -> requires 'items' and 'step'
fan-in -> requires a non-empty 'wait_for'
gate -> requires 'message'
switch -> cases optional
So a switch whose branch table is absent or mistyped — `case:` for `cases:`
is the obvious slip — passes validation with zero errors:
if missing then : ["If step 'x' is missing 'then' field."]
fanout missing all: ["Fan-out step 'y' is missing 'items' field.", ...]
switch typo case: : []
switch no cases : []
and then at run time reports COMPLETED with
`matched_case: "__default__"` — a default it does not even declare — having
dispatched nothing, so the whole run "succeeds". That is the "silent empty
result + COMPLETED" wiring bug the fan-in guard exists to prevent.
An explicitly declared but empty `cases: {}` is still a declaration and
stays valid, pinned by a test.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
3cc1472098 |
fix(workflows): strip the resolved value before matching switch cases (#4143)
`SwitchStep.execute` matched with `str(value)` and no strip. The values a
switch dispatches on are overwhelmingly captured command output, and
`ShellStep` stores `proc.stdout` verbatim, so `run: echo approve` resolves
to "approve\n" — which matches no `approve:` case:
stdout stored : 'approve\n'
matched_case : '__default__' <-- silently wrong
next steps : ['fallback']
The switch falls through to `default:` (or dispatches nothing at all) while
still reporting COMPLETED. A workflow author cannot fix it themselves: the
registered filters are default/join/map/contains/from_json — there is no
`trim`.
spec-kit already treats exactly this as a bug wherever else it matches a
resolved string against declared literals — `evaluate_condition` strips for
this same shell-newline reason, and `InitStep._resolve_bool` does
`resolved.strip().lower()`. Switch case keys are such literals, and this was
the only site not stripping.
`expression_value` still reports the raw value, so nothing downstream loses
information, and a genuine mismatch ("approve-later") still falls through.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
36ff0158b1 |
fix(bundler): reject non-string manifest list members (#4091)
* fix(bundler): reject non-string manifest list members Assisted-by: GitHub Copilot (model: gpt-5.6-sol, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(bundler): clarify string list validation Assisted-by: GitHub Copilot (model: gpt-5.6-sol, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
f5ab7796dd |
fix(presets): reject non-mapping catalog mutations (#4094)
Assisted-by: GitHub Copilot (model: gpt-5.6-sol, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
2dddaa54f4 |
fix(workflows): stop offering a condition correction that inverts it (#4230)
* fix(workflows): stop offering a correction that would not repair the condition
`format_condition_correction` wraps whatever it is handed — correct for a
formatter, wrong to advertise as paste-ready for two inputs it cannot repair.
Both reach the never-evaluated branch, and both were being suggested:
condition: " " -> "{{ }}"
{{ inputs.name == 'abc -> "{{ inputs.name == 'abc }}"
Measured what pasting each one does, rather than assuming:
" " is True -> "{{ }}" is False
"{{ inputs.name == 'abc" is True -> "{{ inputs.name == 'abc }}" is False
The blank core interpolates to the empty string. The open quote survives
wrapping, so the raw-close fallback evaluates a truncated comparison whose
result is the string "False", which `evaluate_condition` then reads as the
`false` keyword. In both cases the advertised correction silently inverts the
condition — a different defect, not a fix.
Add `format_condition_remediation`, which the three step validators now call in
place of hand-building the sentence. It offers the correction only when wrapping
would actually repair the input, and otherwise names the fault, matching the
call already made for `condition_has_malformed_expression_block`.
`_has_unbalanced_quote` uses the same left-to-right scan as `_find_block_close`
and `_strip_stray_delimiters`, so "inside a string" means the same thing
everywhere in this module.
I had the second case wrong at first and said the wrapped form "stays always
true" — the new test caught it, and the message and docstring now say inverted.
Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py 133 passed (was 116)
- tests/unit + tests/test_workflows.py 1216 passed (was 1199), 22 failed
before and after — the pre-existing symlink tests needing Windows elevation.
Mutation-checked: removing either gate fails exactly the 9 new parametrised
cases and nothing else.
* fix(workflows): withhold the correction whenever wrapping cannot repair the core
Copilot found two more holes in the previous commit, and both were real.
1. The quote-balance gate was not sufficient. `inputs.name ==` has a non-empty,
quote-balanced core, so a correction was still advertised:
inputs.name == -> "{{ inputs.name == }}" True -> False
The missing operand resolves to None, the comparison evaluates False, and the
author again trades an always-true condition for an always-false one.
2. The message named the wrong mechanism. It said the wrapped form goes through
the raw-close fallback. Measured: `_is_single_expression("{{ inputs.name ==
'abc }}")` is True, so it takes the typed fast path instead.
Stop enumerating broken shapes. `_wrapping_would_not_repair` now reports the
first reason wrapping cannot yield the intended expression — empty core,
unclosed quote, unbalanced bracket, or an operator missing an operand — and the
advice names it instead of offering a suggestion.
`_has_incomplete_operand` reads `_COMPARISON_OPERATORS`, extracted from
`_evaluate_simple_expression`, so the check cannot drift from what the evaluator
actually splits on. The messages now describe the text itself rather than the
interpolator path it will take: asserting an internal route is what made the
previous two versions wrong.
Tests state the property rather than listing shapes:
`test_every_offered_correction_is_a_complete_expression` asserts that anything
advertised as paste-ready survives both validators, so a new malformed shape is
caught by the invariant rather than by another fixture row.
Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py 182 passed (was 133)
- tests/unit + tests/test_workflows.py 1282 passed (was 1233), 22 failed
before and after — pre-existing symlink tests needing Windows elevation.
Mutation-checked, each gate against its own cases: dropping the operand gate
fails 12, the bracket gate 3, and removing an operator from
`_COMPARISON_OPERATORS` fails 1. That last one passed vacuously at first because
the test parametrised over the constant it was checking — the same can't-fail
shape this module rejects — so it is hard-coded now.
* fix(workflows): check every operator position and match bracket types
Copilot found two more, and both were right.
1. `_has_incomplete_operand` inspected only the first occurrence of each
operator, and its end-of-string check covered only trailing boolean keywords:
inputs.a == inputs.b == -> correction still offered, True -> False
and inputs.ready -> correction still offered, True -> False
That is the same defect this PR's parent commit fixed one level up — stopping
at the first match — reintroduced in the gate meant to prevent it. It now
splits on every top-level occurrence and requires every operand to be
non-empty.
A stripped core also loses the space that delimits a word operator, so
`inputs.a not in` matched nothing. `_WORD_OPERATORS` is derived from
`_COMPARISON_OPERATORS` and matched against both ends without it.
2. `_has_unbalanced_bracket` counted depth, so mismatched types cancelled:
inputs.f(] -> correction still offered, True -> False
It tracks opener types on a stack and rejects a non-matching closer.
The docstring Copilot flagged at line 950 is unchanged on purpose: it does not
attribute the inversion to the raw-close fallback, it records that two earlier
versions did and were wrong because `_is_single_expression` accepts the wrapped
form. That thread is marked outdated and refers to the text before `6944920`.
Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py 207 passed (was 182)
- tests/unit + tests/test_workflows.py 1307 passed (was 1282), 22 failed
before and after — pre-existing symlink tests needing Windows elevation.
Mutation-checked: depth-only brackets fails 9, first-occurrence-only fails 3,
dropping the end-of-core word scan fails 16.
* fix(workflows): reject an unregistered filter and prose before suggesting a wrap
Copilot's remaining point was the strongest one on this PR: `reason is None` only
excluded four structural shapes, and structural shapes cannot establish that
wrapping produces a working expression. Two inputs proved it:
inputs.items | length -> offered; wrapped form raises
ValueError("unknown filter 'length'")
he said "hi"\nthen left -> offered; wrapped form resolves to None,
True -> False
The first replaces an always-true condition with a crash, the second inverts it.
Two checks close the gap, both reading the evaluator rather than guessing:
- `_unregistered_filter` walks the top-level `|` segments and reports the first
name missing from `_REGISTERED_FILTERS`, the same tuple `_apply_filter` raises
on.
- `_reads_as_prose` reports a core that is several bare terms with no operator
and no filter joining them. Quoted spans and bracketed groups are skipped, so
`inputs.f('a b')` and `inputs.name == 'two words'` are unaffected, and a `not `
prefix is allowed.
`he said "hi"\nthen left` was in `OFFERED_CORRECTION_INPUTS` only because that
fixture was built as `TRICKY_CONDITIONS + [...]`. TRICKY_CONDITIONS exists to
exercise the formatter's quoting and deliberately contains prose, so reusing it
asserted the wrong thing. The list is explicit now, and the tricky-quoting entries
that really are expressions are carried over by hand — adding prose to that
fixture can no longer widen what this invariant claims.
`inputs.tags | length > 0` was also mine, and `length` is not a registered
filter; it is `join(',')` now.
Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py 212 passed (was 207)
- tests/unit + tests/test_workflows.py 1312 passed (was 1307), 22 failed
before and after — pre-existing symlink tests needing Windows elevation.
Mutation-checked: dropping either new gate fails 3 cases and nothing else.
* fix(workflows): ask the evaluator whether the core parses, instead of guessing
Copilot found two more shapes the structural gates did not know about:
inputs.tags | join -> offered; `join` is registered, but with no argument
`_apply_filter` raises ValueError
inputs.count+1 -> offered; the evaluator has no arithmetic, reads it as
a key named "count+1", and the wrapped form resolves
to None, turning a truthy condition false
That is the fifth shape in four rounds, which is the argument against enumerating
shapes at all. Replace the two structural checks with two that read the evaluator:
- `_evaluator_rejects` runs the core through `_evaluate_simple_expression` against
a probe namespace and returns its own error. Any filter under an unknown name or
in an unsupported form is now reported by the code that will actually run, so
`_unregistered_filter` — which restated the filter table — is gone.
- `_is_not_a_bare_path` covers what a probe cannot: a single-term core is resolved
as a path lookup, so every dotted segment must be an identifier. `count+1` is
not, and neither is prose, so `_reads_as_prose` is gone too.
The probe namespace resolves roots but not leaves, deliberately. A namespace that
answers every lookup also answers `inputs.count+1`, hiding the shape the probe
exists to expose.
Net effect is two helpers fewer and no restatement of the evaluator's tables.
Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py 230 passed (was 212)
- tests/unit + tests/test_workflows.py 1330 passed (was 1312), 22 failed
before and after — pre-existing symlink tests needing Windows elevation.
Mutation-checked: dropping either check fails 6 cases and nothing else.
* fix(workflows): stop the probe rejecting valid expressions, and match the path grammar
Copilot found a false positive in the probe, which is worse than the false
negatives the earlier rounds fixed: it withheld a correction from a condition
that was already correct.
steps.emit.output.stdout | from_json -> refused
inputs.tags | join(inputs.separator) -> refused
Both are valid; the first is exercised in tests/test_workflows.py. The probe
hands `from_json` a dict and it raises, so treating every probe error as a
rejection blamed the author for the placeholder's type. `_evaluator_rejects` now
reports only the two failures `_apply_filter` raises about the expression itself
-- an unknown filter name, and a registered filter used in an unsupported form.
Everything else a probe run raises is about probe values.
`_TERM_SUFFIX` also accepted any bracket contents and repeated indexes, while
`_resolve_dot_path` matches `^([\w-]+)\[(\d+)\]$` -- one numeric index. So
`inputs.tags[foo]` and `inputs.matrix[0][1]` passed as paths, resolved to None,
and were offered a correction that turns a truthy condition false. `_PATH_SEGMENT`
is that grammar now. It also replaces `str.isidentifier`, which was wrong in the
other direction: the resolver allows a hyphen and a leading digit in a key name.
Lint: this branch had added 3 ruff errors (2x SIM102, PIE810/UP037 on new code)
and left a top-level class without its blank lines. `ruff check` on this file is
back to the 5 pre-existing errors on `main`, all in code this PR does not touch.
On the E305 comment specifically: `ruff rule E305` reports "Selection `E305` has
no effect because preview is not enabled", and `ruff check --select E305` on this
file passes, so the repository's CI does not report it. The blank lines were still
wrong and are fixed.
Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py 236 passed (was 230)
- tests/unit + tests/test_workflows.py 1336 passed (was 1330), 22 failed
before and after -- pre-existing symlink tests needing Windows elevation.
Mutation-checked: treating every probe error as a rejection fails 2, loosening
the path grammar fails 2.
* fix(workflows): validate operands recursively, and keep probe-value errors out
Copilot found three more, and the first explains why this took so many rounds:
every gate so far only inspected the shape it was written for.
inputs.a === inputs.b -> offered; splits cleanly on `==`, and the evaluator
reads `= inputs.b` as a path, resolving to None
bogus == 'x' -> offered; unknown root, same result
inputs.payload | from_json() -> offered; raises at run time
`_unresolvable_term` replaces `_is_not_a_bare_path` and walks operands the way
`_evaluate_simple_expression` does -- filters, `or`/`and`/`not`, comparisons --
down to the leaves. A leaf must be a literal or a dotted path rooted in
`_NAMESPACE_ROOTS`, the roots `_build_namespace` actually supplies. Both shapes
above fall out of that without either being named.
`_evaluator_rejects` now keeps only the errors `_apply_filter` raises about the
filter *expression*. Those quote the segment back as `got '| ...'`; its value
errors name the type they received, which under a probe is the placeholder. The
previous prefix list missed `from_json()` (a wiring error) and, when widened by
filter name, wrongly rejected `steps.emit.output.stdout | from_json` (a value
error) -- the regression the round before had just fixed.
One case fell out that no review raised: `_find_top_level` matches " and " with
literal spaces, so a newline before the keyword is not an operator.
`inputs.x == 1\nand inputs.name == 'abc'` evaluates False wrapped, where the same
expression with a space evaluates True. It was in the offered fixture; it is a
refusal case now.
Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py 253 passed (was 236)
- tests/unit + tests/test_workflows.py 1353 passed (was 1336), 22 failed
before and after -- pre-existing symlink tests needing Windows elevation.
- ruff check on this file is back to the 5 errors already on main.
Mutation-checked: dropping the recursion fails 15, dropping the namespace-root
check fails 5, treating every probe error as a rejection fails 2.
* fix(workflows): mirror the evaluator's literal and root tests exactly
Three more from Copilot, all cases where my check approximated the evaluator
instead of matching it:
1e3 -> offered; no "." so the evaluator calls int(), which fails, and
it falls through to a path lookup. float() alone accepted it.
'a' 'b' -> offered; the evaluator requires the opening quote's match to be
the final character, which first/last-character equality is not.
inputs[0] -> offered; `_build_namespace` hands back mappings, so an indexed
root resolves to None however the index is written.
All three are truthy before wrapping and False after, which is the inversion this
change exists to prevent.
`_looks_numeric` and `_is_literal` now use the evaluator's own tests rather than a
looser stand-in, and the root segment is matched without stripping an index off it
first.
Not fixed, and worth being explicit about: `inputs.tags | join(5)` is still
offered. `join` always raises for a non-string separator, but that is a *type*
rule, and `_evaluator_rejects` deliberately ignores value errors because under a
probe they usually describe the placeholder rather than the author's text. The two
cannot be told apart from the message alone -- `join: expected a string separator,
got int` and `join: ..., got NoneType` differ only in a type name the probe may
have supplied. Catching it means encoding each filter's argument types in the
validator, which is the reimplementation this PR has been backing away from.
Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py 267 passed (was 253)
- tests/unit + tests/test_workflows.py 1367 passed (was 1353), 22 failed
before and after -- pre-existing symlink tests needing Windows elevation.
- ruff check on this file is back to the 5 errors already on main.
Mutation-checked: restoring the bare float() fails 2, restoring the
first/last-character quote test fails 3.
* fix(workflows): mirror list literals and filter arguments in the operand check
Two shapes the leaf check did not mirror, each wrong in the opposite
direction.
A list literal is a term the evaluator understands -- it recurses into
the elements rather than resolving the brackets as a name. Resolving
them as a path reported `"['x', 'y']" is not a name the evaluator can
resolve` and withheld the correction from `inputs.tag in ['x', 'y']`,
a condition wrapping repairs completely.
A filter argument is an ordinary operand to `_apply_filter`, which
evaluates it with `_evaluate_simple_expression` like any other.
Skipping it offered `inputs.tags | join(bogus)` as paste-ready:
`bogus` is no namespace root, arrives as None, and the wrapped form
raises `join: expected a string separator, got NoneType`. Parsed with
the same pattern `_apply_filter` uses, so a form this does not
recognize is left to the evaluator probe rather than guessed at.
Every case is asserted against what the evaluator does with the
wrapped form, not against a restatement of the check.
* fix(workflows): let an indexed `item` root keep the correction
`item` is the only namespace root that is not always a mapping.
`StepContext.item` is `Any` and a fan-out assigns the item value
itself, so when that value is a list `_resolve_dot_path` indexes it and
`item[0] == 'x'` resolves. Rejecting every indexed root withheld the
correction from a condition that evaluates.
The other roots come back from `_build_namespace` as mappings, so the
index branch finds no list and returns None however the index is
written. The strip is therefore for `item` alone, and the paired test
pins that it does not widen into "any indexed root".
This narrows the root check added earlier in this branch, which was
written as though every root were a mapping.
|
||
|
|
d3f9212701 |
fix: use chunked read for integration and preset manifest hash (#3843)
* fix: use chunked read for integration and preset manifest hash Replace unbounded fh.read() with chunked iteration to prevent excessive memory allocation on large or corrupted manifest files. Applies to both integrations/catalog.py and presets/__init__.py get_hash() methods. * test: verify full hash value in get_hash() tests to cover chunked path The existing tests only checked the sha256: prefix, which would pass even if the chunked hash was broken. Now verify the complete hash matches hashlib.sha256(content).hexdigest() to exercise the multi-chunk path introduced by the chunked read change. |
||
|
|
58a7edaf5a |
fix(presets): reject duplicate provides.templates name+type entries (#4191)
PresetResolver._manifest_declared_template returns the FIRST
'provides.templates' entry matching a given (name, type) pair:
for tmpl in manifest.templates:
if tmpl.get("name") == template_name and tmpl.get("type") == template_type:
...
return tmpl, ...
So a preset.yml declaring two templates with the same (name, type) --
e.g. two "command"/"specify" entries pointing at different files -- had
its second entry silently unreachable, while PresetManifest.templates
still counted and exposed both. PresetManifest._validate never checked
for this.
Reject the duplicate at manifest-validation time instead, matching the
sibling fix already applied to ExtensionManifest's provides.templates/
provides.scripts (commit
|
||
|
|
77528dc48b |
fix(bundler): decode a downloaded (non-zip) bundle manifest as UTF-8 (#4190)
_download_remote_manifest's non-zip branch fed the downloaded bytes
straight to `yaml.safe_load(io.BytesIO(raw))`. PyYAML's Reader
auto-detects a UTF-16 BOM on a byte stream, so a well-formed UTF-16
bundle.yml (a realistic PowerShell `Out-File`/`>` output) was silently
*accepted* here, while `yamlio.load_yaml` decodes local sources strictly
as UTF-8 and rejects the identical content with "Could not read ...".
BEFORE: a UTF-16 manifest downloaded via `bundle info`/`install`
parses successfully -- exit code 0, no warning.
AFTER: rejected with "... could not be read: ..." -- exit code 1,
matching local directory and .zip sources.
This is the same divergence, in the sibling branch of the same function,
that was just fixed for the .zip case in commit
|
||
|
|
fa19e1c68b |
[bug-fix] Fix qodercli-skills-migration: migrate QodercliIntegration to SkillsIntegration (#4205)
* Fix qodercli-skills-migration: migrate QodercliIntegration to SkillsIntegration Apply the remediation from the bug assessment on issue #4199. Qoder IDE 1.24+ dropped .qoder/commands/ scanning in favour of the skills layout (.qoder/skills/{skill-name}/SKILL.md). Migrated QodercliIntegration from MarkdownIntegration to SkillsIntegration, updating config[commands_subdir] to 'skills' and registrar_config[dir] to '.qoder/skills' with extension '/SKILL.md'. Updated tests to use SkillsIntegrationTests base mixin. Refs #4199 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(qodercli): resolve failing skills-flag test and slash invocation Builds on the qodercli->SkillsIntegration migration (PR #4205). Qoder IDE 1.24+ is always skills-based, so it should not expose a --skills toggle. Override the inherited SkillsIntegrationTests.test_options_include_skills_flag to skip (mirroring Grok/Zed/Droid) and add a test asserting no --skills option, plus a requires_cli/name/multi_install_safe check. Also add "qodercli" to ALWAYS_SLASH_AGENTS so hooks and next-steps render the hyphenated /speckit-<name> invocation instead of the legacy dotted /speckit.<name> form. Fixes the single failing test reported for #4199. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 43394151-ce2a-432d-9cc5-88f587d1b570 * fix(qodercli): migrate legacy extension commands Retire old flat Qoder extension commands only after their replacement skills are successfully written. Cover old-layout upgrades and both slash invocation states, and update the integration reference path. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Copilot-Session: 43394151-ce2a-432d-9cc5-88f587d1b570 |
||
|
|
145e5e6889 |
fix(workflows): reject a condition that has no {{ }} block (#4182)
* fix(workflows): reject a condition that has no {{ }} block
`evaluate_condition` resolves its argument through `evaluate_expression`,
which only substitutes `{{ ... }}` blocks. A string with no such block
comes back unchanged and — unless it reads `true`/`false` — is then
coerced by `bool()`. So a condition authored without the braces is never
evaluated at all:
evaluate_condition("inputs.count > 100", ctx) -> True
evaluate_condition("{{ inputs.count > 100 }}", ctx) -> False
with `inputs.count == 5` in both cases. An `if` step always takes `then`,
and a `while`/`do-while` step always runs to `max_iterations` — ten agent
invocations for a loop the author expected to stop.
This is the same silent-truthiness authoring mistake the three step
validators already reject for a list/dict/number condition, and it is
easier to make: GitHub Actions accepts a bare expression in `if:`, so the
brace-less form is a habit to bring here.
Adds `condition_is_never_evaluated()` and wires it into the `if`,
`while` and `do-while` validators, so the mistake surfaces at validation
with the corrected form spelled out. Boolean literals, real bools, empty
strings and any string containing `{{` stay valid — runtime behaviour is
unchanged.
* fix(workflows): flag an unterminated {{ and quote the correction safely
Two gaps in the condition validator, both raised in review.
An opening `{{` with no `}}` after it is never substituted either:
_interpolate_expressions takes its `raw_close == -1` branch and appends
the tail verbatim. So `condition: "{{ inputs.count > 100"` -- and the
reversed `"}} inputs.count > 100 {{"`, whose only `{{` is last -- come
back unchanged and are coerced to true exactly like a brace-less string.
The helper now looks for a complete block rather than an opening one.
The suggested correction was interpolated into a double-quoted scalar,
so a condition containing a double quote produced YAML that does not
parse: `condition: "{{ inputs.name == "zzz" }}"` raises a ParserError.
format_condition_correction() now picks the quoting from the content and
drops a stray delimiter instead of nesting a second one, so the message
stays paste-ready. All three validators share it.
Tests: 30 more cases -- the incomplete forms, and a YAML round trip over
conditions holding single quotes, double quotes, both, and backslashes,
asserting each correction loads back exactly and is not re-flagged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(workflows): share the evaluator's quote-aware scan, and quote with json.dumps
Both follow-up review points were right.
The completeness check used a plain `find("}}")`, but the substituter closes a
block with a quote-aware scan. So `condition: "{{ inputs.x == '}}'"` looked
complete to the validator while `_interpolate_expressions` found no close, fell
to its raw-close branch, evaluated a truncated body and left residual text
(`False'`) -- a non-empty string, hence true. Rather than restate the quote
rules a third time, the scan moves out of `_interpolate_expressions` into
`_find_block_close`, which the validator now calls: the check and the
substitution it predicts can no longer disagree. A `}}` that is genuinely
inside a string argument still does not close early, so
`{{ inputs.text | default('}}') }}` and `{{ inputs.x == '}}' }}` stay accepted.
The correction's quoting enumerated the characters it escaped, and the
enumeration was short: a condition loaded from a YAML literal block can carry a
newline, which a double-quoted scalar folds, so the corrected form did not
round-trip. `json.dumps` decides it instead -- every JSON string is a valid
YAML double-quoted scalar and it escapes quotes, backslashes, newlines and the
other control characters. `ensure_ascii=False` keeps a non-ASCII operand
readable rather than expanding it into numeric escapes.
Tests: 70 -> 83. The quoted-delimiter condition joins the incomplete-block set,
and the round-trip set gains multiline, newline-with-quote, tab, carriage
return and non-ASCII operands. All four new cases fail on the previous commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(workflows): flag a whitespace condition, and stop the correction nesting a block
Two review findings, both reproduced against the code before changing it.
**1. Non-empty whitespace was excluded, and it should not have been.**
The docstring claimed a whitespace condition "coerces to False, which is a
definite answer". That is true only of the empty string. Measured:
evaluate_condition("") -> False
evaluate_condition(" ") -> True
evaluate_condition("\t\n ") -> True
`evaluate_condition` strips only while testing the true/false keywords, then
falls through to `bool()` on the raw string -- and
`test_condition_whitespace_only_string_stays_truthy` pins that on purpose. So
`condition: " "` is exactly the silent always-true this helper exists to
catch, and it was sailing through. Fixed at validation time rather than in the
evaluator, because that runtime behaviour is deliberate.
The empty string stays excluded: it really does coerce to False.
**2. The correction only removed edge delimiters, so it could nest one.**
"prefix {{ inputs.ready" -> "{{ prefix {{ inputs.ready }}"
The suggestion carried an unclosed inner block, and because its *outer* block
was complete, `condition_is_never_evaluated` waved the corrected form straight
back through. Same for a trailing `}}`.
`_strip_stray_delimiters` now removes every delimiter, and is quote-aware for
the reason the rest of this module is: `inputs.x == '}}'` holds a delimiter as
data, and a blanket `re.sub` would eat it and change what the condition
compares. `_find_top_level` could not be reused -- it counts `{`/`}` as bracket
depth, so it never reports a `{{` as a token at all.
"prefix {{ inputs.ready" -> "{{ prefix inputs.ready }}"
"inputs.ready }} suffix" -> "{{ inputs.ready suffix }}"
"{{ inputs.x == '}}'" -> "{{ inputs.x == '}}' }}" (data kept)
'{{ inputs.name == "a b"' -> '{{ inputs.name == "a b" }}' (spacing kept)
Whitespace collapses only where a delimiter was removed; inside a quoted
operand it is untouched.
Tests: the two fixtures that asserted whitespace was valid are corrected, and
five cases added for interior delimiters, quoted delimiters and quoted spacing.
87 pass in tests/unit/test_condition_expression_block.py.
tests/test_workflows.py is 20 failed / 903 passed both with and without this
change -- all twenty are symlink tests that need Windows Developer Mode, and
the counts are identical with the diff stashed.
* fix(workflows): separate a malformed block from one that is never evaluated
Third review finding, and like the first two it reproduces. `condition_is_never_evaluated`
returned True for any `{{` the quote-aware scan could not close -- but
`_interpolate_expressions` does not treat those alike. Its own comment spells out
two sub-cases, and only one is "never evaluated":
* no raw `}}` in the tail -> the text is emitted verbatim, so bool() makes it
true. Genuinely uninterpolated.
* a raw `}}` further along -> that is used as the close and the truncated body
*is* evaluated.
Measured:
{{ inputs.count > 100 -> True (never evaluated)
}} inputs.count > 100 {{ -> True (never evaluated)
{{ inputs.x == '}}' -> True (raw-close path)
{{ inputs.missing | default('oops }} -> raises ValueError
That last one made the old message wrong on both halves: it is evaluated, and it
does not end up true -- it ends the run in `_apply_filter`.
Adds `condition_has_malformed_expression_block` and gives it its own branch in the
three validators, because the two faults need opposite advice: one says "you forgot
the braces", the other says "your delimiters or quotes do not balance". The two
predicates are mutually exclusive, pinned by a test over every fixture.
The malformed branch deliberately offers **no** paste-ready correction. The fault is
unbalanced quoting, so the quote-aware stripper cannot tell operand from delimiter --
for `{{ inputs.missing | default('oops }}` it emits `"{{ inputs.missing | default('oops }} }}"`,
which is not a fix. This is the same "avoid offering an automatic correction for
malformed-block cases" the reviewer raised earlier; it applies exactly here.
Also renders a blank correction as `"{{ }}"` rather than the double-spaced `"{{ }}"`
that concatenation produced for a whitespace-only condition.
106 pass in tests/unit/test_condition_expression_block.py. Across
tests/test_workflows.py + tests/unit the run is 22 failed / 1189 passed, and 22
failed / 1170 passed with this diff stashed -- identical failures, all Windows
symlink cases, none touching conditions or expressions.
* fix(workflows): scan every expression block, not just the first
Both condition validators stopped at the first `{{`. A condition whose first
block closes was accepted regardless of what followed, so a later unterminated
block escaped validation entirely — the case Copilot raised:
{{ true }} and {{ inputs.ready -> both validators returned False
Interpolation leaves `and {{ inputs.ready` in the result and bool() makes the
condition always true, which is exactly the silent-branching defect these
validators exist to catch. The same hole applied to the malformed class:
{{ inputs.name }} {{ inputs.missing | default('oops }} -> raises at run time
Add `_first_unclosable_block`, which walks blocks the way
`_interpolate_expressions` does — continuing past each block that closes — and
reports how the first unclosable one will fail: `evaluated` when a raw `}}`
follows (the fallback truncates and evaluates), `verbatim` when none does.
Both validators now read from it, so they cannot disagree with the substitution
they predict.
Two wording fixes fall out of scanning further:
- The never-evaluated message said the condition "has no complete '{{ }}'
block". With an earlier complete block that is false, so it now says the
condition "is not a single complete '{{ }}' block".
- `condition_has_malformed_expression_block`'s docstring said the truncated body
raises ValueError. It does for `default('oops`, but `{{ inputs.x == '}}'`
evaluates to the residual `"False'"` instead. Measured both; the docstring now
says either can happen and the error message never claimed otherwise.
Verified on Python 3.11:
- tests/unit/test_condition_expression_block.py 116 passed (was 106)
- tests/unit + tests/test_workflows.py 1199 passed (was 1189), 22 failed
before and after — all pre-existing symlink tests that need Windows elevation.
Mutation-checked: restoring the stop-after-first-block behaviour fails exactly
the 10 new parametrised cases and nothing else.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ead30d9cfb |
fix(integrations): report a falsy non-mapping integration descriptor as a shape error (#4187)
* fix(integrations): report a falsy non-mapping integration descriptor as a shape error
`IntegrationDescriptor._load` did `yaml.safe_load(fh) or {}`. `_validate`
opens with an `isinstance(self.data, dict)` check, so a truthy non-mapping
(`- a`, `hello`) is reported correctly -- but `or {}` replaced the falsy
non-mappings with an empty mapping first, so those descriptors were
reported as "Missing required field: schema_version" instead of the wrong
shape:
'false' -> Descriptor root must be a YAML mapping, got bool
'0' -> Descriptor root must be a YAML mapping, got int
"''" -> Descriptor root must be a YAML mapping, got str
'[]' -> Descriptor root must be a YAML mapping, got list
`safe_load` also returns None for an explicit null scalar (`null`, `~`,
`NULL`) as well as for an empty document, so those three hit the same
masking. Use `yaml.compose`, which yields no node only for a genuinely
empty document, to tell the two apart -- only an empty document still
normalizes to `{}` and reports its missing fields.
Same bug class just fixed in the sibling overlay-manifest loader
(upstream commit
|
||
|
|
7e48738e26 |
fix(workflows): validate dispatch defaults (#4181)
* fix(workflows): validate dispatch defaults * fix(workflows): validate dispatch defaults on resume --------- Co-authored-by: root <kinsonnee@gmail.com> |
||
|
|
92e8ab56b4 |
fix(utils): narrow bare except Exception in merge_json_files (#4189)
merge_json_files's read of the existing JSON file caught bare
`Exception` around `json5.load`, so a real bug there (e.g. a
`TypeError`/`AttributeError`) was silently treated the same as a normal
parse failure -- `None` returned, existing settings preserved untouched,
nothing surfaced unless `verbose`. Only `OSError` (inaccessible file) and
`ValueError` (malformed JSON5 -- json5's decode error is a `ValueError`
subclass) are expected outcomes here; anything else should propagate.
Same bug, same fix shape, as the caller `handle_vscode_settings`, whose
own bare `except Exception` was just narrowed to `(OSError, ValueError,
KeyError)` in commit
|
||
|
|
6b7f4aa844 |
fix(powershell): stop Out-Null swallowing setup-tasks AVAILABLE_DOCS lines (#4188)
Test-FileExists / Test-DirHasFiles report their line with Write-Output and
ALSO return $true/$false -- both on the Success stream. setup-tasks.ps1's
text-mode branch piped each call to `| Out-Null` to discard the boolean,
which discarded the report line with it, so AVAILABLE_DOCS: printed with
nothing under it:
BEFORE (measured, powershell.exe -NoProfile -File ...):
FEATURE_DIR:...\specs\001-my-feature
TASKS_TEMPLATE:...\tasks-template.md
AVAILABLE_DOCS:
(3 lines)
AFTER:
FEATURE_DIR:...\specs\001-my-feature
TASKS_TEMPLATE:...\tasks-template.md
AVAILABLE_DOCS:
[OK] research.md
[FAIL] data-model.md
[FAIL] contracts/
[FAIL] quickstart.md
(7 lines)
The bash twin (scripts/bash/setup-tasks.sh) lists every document under that
header, so the PowerShell variant silently returned less information for
the same inputs.
Same bug, same fix shape (filter out only the boolean with Where-Object)
as the sibling that was just fixed in check-prerequisites.ps1 (upstream
commit
|
||
|
|
a5c3ba4acf |
fix(init): stop specify init hanging on arrow-key pickers in agent harnesses (#4178)
* fix(init): stop specify init hanging on arrow-key pickers in agent harnesses Agent harnesses often allocate a PTY so isatty is true, but they cannot send arrow keys. Fail fast when stdin is not a TTY, and add --non-interactive so scripted init applies defaults instead of hanging. Fixes #4152. * test(init): assert --non-interactive never prompts for URL extension trust Cover the HTTPS --extension confirmation path when stdin is a TTY: deny without --trust-extension-urls, and install with it, both without calling typer.confirm. |
||
|
|
ae6033384f |
fix: confine event hook script paths to the project tree (#4133)
* fix: confine event hook scripts to the project tree Event dispatch joined the first scripts: token onto the .specify or extension base with Path. An absolute token discarded the base and ran a host binary. Reject anchored tokens and require the resolved path to stay inside the project root. Assisted-by: Grok (model: grok-4.6, supervised) Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca> * fix: refuse stale specify_cli.events without path confinement Generated dispatchers only delegate when EVENT_SCRIPT_PATH_CONFINEMENT is True, so an older global install cannot bypass the project-tree guard. Assisted-by: Grok (xAI, under direct human supervision) Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca> --------- Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca> |
||
|
|
fa3a5c5ce7 |
Clarify extension catalog trust model in docs, help, and messaging (#4177)
* Clarify extension catalog trust model in docs, help, and messaging (#4176) Extension catalog management gave no explanation of why the community catalog is discovery-only, and the install-error text nudged users to flip a discovery catalog to install_allowed — exactly the wrong move. - Docs: add a "discovery-only vs. install sources" trust-model section, document `add --from <url>` as the lightweight vetted-install path, and stop implying you should make community installable. - Help: expand the `catalog` app and `--install-allowed` help to state the vetting intent instead of bare mechanics. - Messaging: rewrite the not-installable errors in `add`, `search`, and `info` to point at `--from` and self-curated catalogs, and to say explicitly not to flip a discovery-only catalog to install_allowed. - `catalog list` now prints trust-model guidance when a discovery-only catalog is active. - Tests cover the new list guidance (present/absent). Deliberately does not add a verb to toggle install_allowed on an existing catalog: discovery-only is a security boundary, not an inconvenience. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a86c498e-f129-4422-9983-d1a33513fd4d * Address PR review: copy-pasteable install hint and accurate --from warning (#4176) - The discovery-only "install directly" hint used the user-typed argument, which can be a display name with spaces (resolved via search) and would break when copied as a shell command. Emit the resolved catalog ID (ext_info['id']) instead. Added a regression test. - The `--from` untrusted-source warning claimed the URL was "not listed in any of your configured extension catalogs", which is false for a URL copied from a discovery-only catalog — the exact flow this PR documents. Reword it to state the install is bypassing trusted (install-allowed) catalogs, which is accurate regardless of discovery-catalog membership. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a86c498e-f129-4422-9983-d1a33513fd4d * Harden install hints against catalog-controlled IDs; expose archive URL (#4176) Second review round on #4177. Shell-safety: catalog entry IDs (especially from discovery-only catalogs) are not validated during catalog merge, and rich.markup.escape only neutralizes Rich markup, not shell metacharacters. A malicious ID like `foo; rm -rf ~` was interpolated into the `specify extension add ... --from` command we encourage the user to copy. Add `_command_safe_id`, which only emits an ID matching the manifest rule `^[a-z0-9-]+$` (via VALID_EXTENSION_ARTIFACT_NAME_PATTERN) and otherwise falls back to a literal `<extension-id>` placeholder. Applied to every suggested command in `add`, `search`, and `info`. Discoverability: the documented `--from <archive-url>` flow gave no CLI path to obtain the URL. `extension info` now prints the candidate `download_url` for a discovery-only entry (clearly flagged as needing vetting), and the docs show `extension info <name>` as the way to get the archive URL. Tests cover the resolved-ID hint, the unsafe-ID neutralization, and pass the full extensions + CLI suites (635). Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a86c498e-f129-4422-9983-d1a33513fd4d * Reject leading-hyphen catalog IDs; test info archive-URL branch (#4176) Third review round on #4177. _command_safe_id: an ID like `--force` satisfies the manifest character rule `^[a-z0-9-]+$` but Typer parses a leading hyphen as an option rather than the positional extension argument, so an untrusted catalog could still yield a non-copyable or option-altering suggested command. Reject a leading hyphen and fall back to the `<extension-id>` placeholder. Tests: cover the new `extension info` discovery-only branch that surfaces the candidate `download_url` (plus the no-URL fallback), and the leading-hyphen rejection. Full extensions suite green (528). Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a86c498e-f129-4422-9983-d1a33513fd4d --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a86c498e-f129-4422-9983-d1a33513fd4d |
||
|
|
671c6034a8 |
test(presets): normalize whitespace in resolve output assertion to prevent terminal line-wrap failures (#4166)
Signed-off-by: aoright <102943475+aoright@users.noreply.github.com> |
||
|
|
7f36b11da5 |
fix(workflows): clean up download temp file on interrupt or typer.Exit (#4134)
`specify workflow add --from <url>` creates a delete=False temp file before streaming the response body into it. The except clauses around that read only handled typer.Exit (re-raise, no cleanup) and Exception (cleanup + re-raise). KeyboardInterrupt is a BaseException, so Ctrl+C during the size-limited read skipped both and left the file behind in the system temp directory. Adds a shared cleanup helper and a BaseException handler so any exit path after the temp file is created -- error, typer.Exit, or interrupt -- unlinks it, matching the existing best-effort cleanup on other download errors. Assisted-by: Claude Sonnet 5 (autonomous) |
||
|
|
39c36c4144 |
fix(workflows): report a falsy non-mapping overlay manifest as a shape error (#3884)
* fix(workflows): report a falsy non-mapping overlay manifest as a shape error
`ProjectOverlaySource.collect` did `yaml.safe_load(...) or {}`.
`validate_overlay_yaml` opens with an `isinstance(data, dict)` check, so a
truthy non-mapping is reported correctly — but `or {}` replaced the falsy
non-mappings with an empty mapping first, so those files were reported as
three bogus missing-field errors instead of the wrong shape:
'- a' -> ['Overlay manifest must be a mapping.']
'hello' -> ['Overlay manifest must be a mapping.']
'[]' -> ["Overlay 'id' is required...", "'extends' is required...",
"'edits' is required..."]
'false' -> same three
'0' -> same three
"''" -> same three
The sibling reader for these same files in the same package, `_read_overlay`
in overlays/_commands.py, does not coerce.
Only an empty document (None) now becomes an empty mapping, so a genuinely
empty overlay still reports its missing fields.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(workflows): distinguish an empty document from an explicit YAML null
Review catch: `safe_load` returns None for an explicit null scalar
(`null`, `~`, `Null`, `NULL`) as well as for an empty document, so the
`data is None` normalization still converted those manifests to `{}` and
they still received missing-field errors instead of the mapping-shape
error.
Use `yaml.compose`, which yields no node only for a genuinely empty
document, to tell the two apart. Measured:
empty doc -> missing-field (correct)
explicit null -> SHAPE
explicit ~ -> SHAPE
NULL -> SHAPE
[] false 0 '' -> SHAPE
- a / hello -> SHAPE
Extends the parametrized cases with null/~/NULL, and corrects the article
before `isinstance` in the docstring.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
21fb1bbbb3 |
fix(bundler): resolve built-in step types when checking bundle component references (#3885)
* fix(bundler): resolve built-in step types when checking bundle references `_resolved_locally` gives three of the four component kinds a "is it bundled with Spec Kit?" check before the installed-in-project one: presets -> _locate_bundled_preset or PresetManager.get_pack extensions -> _locate_bundled_extension or ExtensionManager...is_installed workflows -> _locate_bundled_workflow or WorkflowRegistry.is_installed steps -> StepRegistry.is_installed <-- no bundled check `StepRegistry` tracks *community* step types installed under `.specify/workflows/steps/`. Spec Kit ships 11 step types as built-ins registered in `STEP_REGISTRY`, so every one of them looked unresolved: steps/shell -> False steps/gate -> False steps/command -> False steps/if -> False A bundle declaring a dependency on any built-in step type was therefore reported as an unresolved reference — an error online, a warning offline. There is no `_locate_bundled_step` to mirror, because step types are not an on-disk asset directory; `STEP_REGISTRY` is the equivalent check, and is what `specify workflow step info` reports as "built-in". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bundler): check an immutable built-in step set, not the mutable registry Review catch: `STEP_REGISTRY` is not limited to bundled steps. `load_custom_steps` adds project-installed ids to that process-global mapping and never removes them, so in a long-lived process a community step loaded while working on project A would be accepted as "bundled" when validating a bundle for project B — before B's own StepRegistry is consulted. Snapshot the shipped ids into `BUILTIN_STEP_TYPES` immediately after `_register_builtin_steps()`, before `load_custom_steps` is even defined, and check that frozenset instead. Verified: with the check on STEP_REGISTRY the new cross-project test fails (a leaked community id resolves as bundled); with BUILTIN_STEP_TYPES it passes. 1 failed, 5 passed -> 6 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d6e09a17c4 |
fix(workflows): validate non-string step types (#4111)
Return an actionable validation error when a workflow step type is a YAML list or mapping instead of raising during registry membership checks. Assisted-by: OpenAI Codex (model: GPT-5, autonomous) |
||
|
|
672f812927 |
Harden community submission workflow output allowlists (#4103)
* Harden community submission workflow outputs Restrict extension and preset submission PRs to the expected catalog and docs files. * test: check community allowlists pairwise --------- Co-authored-by: root <kinsonnee@gmail.com> |
||
|
|
b485cd8c1f |
fix(integrations): dispatch goose commands via goose run (#2416) (#3781)
* fix(integrations): dispatch goose commands via `goose run` (#2416) `YamlIntegration` never overrode `build_exec_args()`, so `GooseIntegration` inherited the `IntegrationBase` no-op that returns `None`. Callers read `None` as "this CLI is unavailable", so every workflow command/prompt step targeting Goose reported `CLI not found or not installed` even with `goose` on PATH. Reproduced with the agent CLI present on PATH (shutil.which stubbed to a real path, subprocess.run stubbed): amp -> completed argv=['amp', '-p', '/speckit.specify'] opencode -> completed argv=['opencode', 'run', '--command', 'speckit.specify'] goose -> FAILED "integration 'goose' CLI not found or not installed" Implement `build_exec_args()` for Goose. Per the goose CLI docs there is no `-p` flag; the non-interactive entry point is `goose run`, which takes `-t/--text` for free-form text, `--recipe` for a stored recipe, `--params KEY=VALUE` for recipe parameters, plus `--model` and `--output-format`. Spec Kit installs its commands as Goose *recipes* under `.goose/recipes/`, each declaring an optional `args` parameter (already enforced by test_setup_declares_args_parameter_for_args_prompt), so a `/speckit.<name> <rest>` invocation maps exactly onto `--recipe <path> --params args=<rest>`. This mirrors `OpencodeIntegration`, which maps the same leading slash-command onto opencode's `--command`. The recipe path is derived from the same two sources `setup()` uses -- `config["folder"]` + `config["commands_subdir"]` and `command_filename()` -- so the dispatch target cannot drift from the installed file; a test asserts the resolved `--recipe` path exists after `setup()`. Dotted extension commands (`speckit.git.commit`) round-trip. Extra args are applied before the canonical flags so Spec Kit's selection stays authoritative, matching opencode. No behaviour change for other integrations, and `requires_cli` is untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(goose): only map the speckit. namespace onto --recipe build_exec_args() treated every prompt starting with "/" as a Spec Kit recipe. Because command_filename() unconditionally re-adds the "speckit." prefix, a free-form slash prompt was silently promoted into a recipe run against a file that was never installed: /help -> --recipe .goose/recipes/speckit.help.yaml /plan the sprint -> --recipe .goose/recipes/speckit.plan.yaml /speckit. -> --recipe .goose/recipes/speckit..yaml PromptStep passes arbitrary prompt: strings to build_exec_args, and both /help and /plan are Goose's own session commands, so this is reachable. Unlike opencode's --command or hermes' -s, which hand a bare name to the agent's own resolver, --recipe is a path Spec Kit synthesizes -- so only the namespace it can actually spell may take that branch. Gate the branch on "/speckit." and fall through to -t otherwise. A bare "/speckit." leaves no stem and also falls through. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(goose): stop asserting an argv that goose would reject test_goose_extra_args_cannot_clobber_prompt_derived_recipe asserted that a duplicated --recipe is merely reordered, on a "last value wins" premise. That premise is wrong for goose: `goose run` is clap-derive based and --recipe/--model/--output-format are single-value args without args_override_self, so a duplicate makes goose exit with "cannot be used multiple times" whichever side comes first. The test passed in pytest while pinning a command line that cannot run. Replace it with an ordering-parity test that asserts only what Spec Kit actually controls: extra args precede the canonical flags (matching opencode/codex/cursor-agent), and Spec Kit never emits a duplicate single-value flag itself. Verified non-vacuous -- it fails if the extra-args hook is moved after the canonical flags. The ordering comment claimed precedence it cannot deliver; corrected to state positional parity only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2b36f0ce94 |
fix(powershell): stop Out-Null swallowing the AVAILABLE_DOCS status lines (#3891)
Test-FileExists / Test-DirHasFiles report their line with Write-Output and
ALSO return $true/$false — both on the Success stream. The callers piped
the whole call to `| Out-Null` to discard the boolean, which discarded the
report line with it, so text mode printed the header and nothing under it:
BEFORE (measured, powershell.exe -NoProfile -File ... -IncludeTasks):
FEATURE_DIR:...\specs\001-f
AVAILABLE_DOCS:
(2 lines)
AFTER:
FEATURE_DIR:...\specs\001-f
AVAILABLE_DOCS:
[OK] research.md
[FAIL] data-model.md
[FAIL] contracts/
[FAIL] quickstart.md
[FAIL] tasks.md
(7 lines)
The bash and Python twins both list every document under that header, so
the PowerShell variant silently returned less information for the same
inputs.
Filter out only the boolean, keeping the report lines. Adds the first
PowerShell text-mode test in this file (every existing PS test is -Json).
File stays ASCII-only (verified 0 non-ASCII bytes).
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
56aec8a936 |
fix: decode the zipped manifest as UTF-8 before parsing (#3958)
Review follow-up: feeding PyYAML the byte stream let its Reader honour a UTF-16 BOM and accept a manifest yamlio.load_yaml rejects, so zip and directory sources diverged. Decode raw as UTF-8 (UnicodeError -> BundlerError 'Could not read ...') then parse, and cover a well-formed UTF-16 manifest in the regression tests. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
618d16e94b |
fix: log progress tracker refresh errors instead of silently swallowing (#3975)
* fix: log progress tracker refresh errors instead of silently swallowing The bare 'except Exception: pass' in StepTracker._maybe_refresh() completely hid rendering bugs in the Rich progress display. Now logs at DEBUG level with full traceback for diagnostics. * test: add regression test for StepTracker refresh error logging - Test that _maybe_refresh logs exceptions instead of silently swallowing - Verify diagnostic message and traceback are recorded in DEBUG logs - Confirm tracker update completes normally despite refresh callback failure Requested by Copilot in PR #3975 |
||
|
|
bfabf4ce65 |
fix(bundler): read the authoritative default_integration field, not only its legacy aliases (#3880)
* fix(bundler): read the authoritative default_integration field
`active_integration()` resolves a project's integration with
data.get("integration") or data.get("id") or data.get("active")
and never looks at `default_integration` — which is the key the CLI
actually writes. `integration_state.set_default_integration` persists
`data["default_integration"] = integration_key`, and the canonical
reader in that module orders it the other way round:
key = state.get("default_integration") or state.get("integration")
So a project initialised by any current version of the CLI looks to the
bundler as though it has no active integration:
{"default_integration": "copilot"} -> None (expected "copilot")
{"integration": "copilot"} -> "copilot" (legacy alias)
That silently changes bundler behaviour that keys off the active
integration, including the FR-019 clash guard, which treats an
undeterminable integration differently from a known one.
Read `default_integration` first and keep the three legacy aliases as
fallbacks for projects initialised by older versions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(bundler): correct the justification for reading default_integration
Review catch: the comment cited a nonexistent
`integration_state.set_default_integration` and overstated the impact.
The real writer is `write_integration_json`, which persists BOTH
`integration` and `default_integration` (integration_state.py:248-250), so
a marker produced by the current CLI already resolved through the
`integration` alias. Measured:
{"integration": "copilot", "default_integration": "copilot"} -> 'copilot'
{"default_integration": "copilot"} -> 'copilot' (after fix)
Reword both the source comment and the test docstring: this is about which
field is authoritative when they disagree, plus resolving a marker that
carries only `default_integration` — not about every current project being
undetectable. The precedence itself still has its precedent, the canonical
reader at integration_state.py:199.
Behaviour unchanged; comments and docstrings only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
b66044ae8e |
fix(auth): treat exact host patterns literally (#4108)
Assisted-by: OpenAI Codex (model: GPT-5, autonomous) |
||
|
|
54f8b2cdf0 |
feat: add Mistral Vibe integration with Claude parity (#4075)
* feat: add Mistral Vibe integration with Claude parity - Add VibeIntegration class with ARGUMENT_HINTS, user-invocable, disable-model-invocation - Add comprehensive test suite matching Claude integration - Support all Spec Kit workflows (py/sh/ps script types) * fix: address Vibe integration issues and test cleanup - Fix Vibe to use .vibe/hooks.toml with toml-vibe format instead of ignored .vibe/settings.json, adding toml-vibe event handler - Remove unsupported argument-hint injection (Vibe schema doesn't support it) - Restructure test file to inherit from SkillsIntegrationTests mixin - Remove all unused imports to pass Ruff F401 checks Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai> * fix: add name field to Vibe hooks and fix toml regex patterns - Add required 'name' field for each Vibe hook in hooks.toml - Fix regex patterns in _merge_vibe_toml_fragment and _remove_vibe_toml_entries to correctly match [[hooks]] blocks instead of [} characters Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai> * fix: align Vibe hooks with HookConfig schema and drop stray devcontainer lock - use Vibe's 'match' field (re:-prefixed regex translation) instead of unsupported 'matcher'; emit only on tool hooks (rejected on post_agent) - limit CANONICAL_TO_NATIVE to Vibe's three hook types (pre_tool, post_tool, post_agent); unsupported events skip with a warning - deduplicate generated hook names (Vibe drops duplicates by name) - add behavioral tests for toml-vibe generation, merging, and teardown - remove accidentally committed .devcontainer/devcontainer-lock.json Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: wrap Vibe hook stdout in structured JSON response envelope Vibe parses any non-empty hook stdout as a JSON HookStructuredResponse; plain text is reported as a hook failure and its output dropped. Add a hook_specific_output envelope to the dispatcher (template and runtime) that emits {"decision": "allow", "hook_specific_output": {"additional_context": ...}} and declare it for all Vibe events: post_tool injects the context, pre_tool/post_agent parse cleanly and ignore it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: quote Vibe hook commands for cmd.exe on Windows hosts Vibe launches hooks via asyncio.create_subprocess_shell, which is %COMSPEC% (cmd.exe) on Windows — POSIX single-quoting is not quoting there, so an interpreter or dispatcher path containing spaces made every hook fail to start. Add a 'cmd' quoting target to _shell_quote (double-quote when needed, embedded quotes doubled per MSVCRT argv rules), resolve it host-side like 'host', and select it for Vibe when generating on a Windows host. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: pin POSIX quoting target in Vibe test for Windows CI runners test_posix_host_keeps_shlex_quoting asserts host (shlex) quoting, but on a Windows runner _vibe_target_os() resolves to 'cmd' and the command is double-quoted. Monkeypatch the target so the test exercises the POSIX path on every platform. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Mistral Vibe <vibe@mistral.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
16f45774a5 |
fix: narrow bare except Exception in VS Code settings merge (#3844)
* fix: narrow bare except Exception in VS Code settings merge Replace overly broad except Exception with (OSError, ValueError, KeyError) to let programming errors like TypeError or AttributeError propagate while still handling expected I/O and parse errors gracefully. * test: verify programming errors propagate through handle_vscode_settings The narrow exception change from 'except Exception' to 'except (OSError, ValueError, KeyError)' was not covered by a regression test. Add a test that monkeypatches merge_json_files to raise TypeError and verifies it propagates rather than being swallowed. |
||
|
|
229022943c |
feat(presets): list presets in resolution/precedence order (#4086) (#4104)
`specify preset list` now sorts installed presets by (priority, id) so the printed order matches the actual resolution/composition order used by PresetRegistry.list_by_priority(). Lower priority number = higher precedence; ties are broken alphabetically by preset id. Adds a header and footer note clarifying the ordering, updates the presets reference docs, and adds tests. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> |
||
|
|
e79fa25f3f |
Fix: scaffold self-contained namespaced preset commands (#4076) (#4082)
* Fix: scaffold self-contained namespaced preset commands (#4076) Preset command templates named `speckit.<ns>.<cmd>` were silently dropped whenever `.specify/extensions/<ns>/` was absent, while `speckit.<cmd>` always scaffolded. The `_extension_installed_for_command` guard filtered purely on name shape, conflating "override of an installed extension's command" with "a preset shipping its own namespaced command." Because a `type: command` template always ships its own body, such a command is self-contained and must scaffold like any short-named command. Remove the name-shape guard at all four call sites (registration, both reconciliation passes, and skills). The reconciliation loop already skips names that resolve to no layers (`if not layers: continue`), and the composed-None branch still cleans up commands whose base layer disappeared. Convert the command-mode "no base layer to compose onto" hard error into a warn + skip, matching the existing behavior in _reconcile_composed_commands so command-mode install and reconciliation stay consistent. Update the two tests that encoded the old drop behavior to assert the new consistent-scaffold contract, and add coverage proving 2-part and 3-part preset commands scaffold identically with no extension installed. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cfc4f1ce-6acb-465a-aa7b-999f2e4197fb * Skip uncomposable commands in skills mode too (PR #4082 review) When _register_commands skips an uncomposable composition command (a wrap/prepend/append with no base layer to compose onto — e.g. the command it wraps comes from an uninstalled extension), install still passed the full manifest to _register_skills. For a command-backed integration in skills mode, _register_skills created the missing skill and fell back to the raw preset body because no `.composed` file existed, materializing a broken SKILL.md — a literal `{CORE_TEMPLATE}` for wrap, or just the preset's own fragment for prepend/append. Previously the raise in _register_commands aborted before skills ran, so this never surfaced. Make _register_skills apply the same skip: for a composition-strategy command with no `.composed` file, resolve the stack and skip when no base exists (resolve_content is None). The skip is silent because _register_commands already warned for the same command in the same pass. Add a regression test proving an uncomposable wrap command renders no skill and never leaks a literal {CORE_TEMPLATE} in skills mode. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cfc4f1ce-6acb-465a-aa7b-999f2e4197fb --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cfc4f1ce-6acb-465a-aa7b-999f2e4197fb |
||
|
|
197dde6253 |
fix(bundler): treat a blank active integration as indeterminate in FR-019 (#3886)
`resolve_install_plan`'s two FR-019 guards are a truthiness test and an
`is None` test:
if active_integration and required != active_integration: # clash
if active_integration is None and not integration_explicit: # indeterminate
An empty string satisfies neither, so it falls through to
`effective_integration = required` and the bundle's pinned integration is
silently adopted — the exact outcome the docstring says the guard prevents
("resolution fails instead of silently adopting the bundle's required
integration").
active=None -> BundlerError: ... could not be determined
active='' (blank) -> effective_integration='copilot' <-- silent adopt
active='claude' -> BundlerError: ... targets integration 'copilot'
Normalise a blank value to None before the guards, and strip first to
match the writer (`integration_state.clean_integration_key`, which returns
`None` for empty/whitespace and strips otherwise) so a padded value is not
reported as clashing with its own unpadded form.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
f2583e675c |
Integrate Junie with dot-to-hyphen behavior and command formatting (#4073)
* Add Junie integration with dot-to-hyphen behavior, command formatting, and file transformations. Based on Cline Integration. * Fix references to Cline in Junie integration and update class/test names for consistency. * Fix references to Cline in Junie integration and update class/test names for consistency. * Modified to generate correct formatting in junie |
||
|
|
b77ca572ca |
Fix Alquimia argument hints after folded descriptions (#4063)
Co-authored-by: root <kinsonnee@gmail.com> |
||
|
|
c1bceb625c |
fix: use bounded read for bundle download HTTP responses (#3764)
* fix: use bounded read for bundle download HTTP responses The bundle download used unbounded resp.read() to read HTTP responses into memory. A malicious or misconfigured catalog server could return an arbitrarily large payload causing OOM. Replace with read_response_limited() capped at MAX_DOWNLOAD_BYTES (50 MiB), consistent with how other download paths in the codebase enforce bounded reads. Add regression test that monkeypatches MAX_DOWNLOAD_BYTES to 100 bytes and verifies oversized responses are rejected. * fix: remove duplicate import of MAX_DOWNLOAD_BYTES and read_response_limited |
||
|
|
bd595cf838 |
fix(claude): make argument-hint injection fold-aware for long descriptions (#4045)
* fix(claude): make argument-hint injection fold-aware for long descriptions ClaudeIntegration.inject_argument_hint spliced argument-hint: "..." as a raw text line right after the first line starting with "description:". When a description is long enough for the YAML dumper to fold it across indented continuation lines, that splice landed inside the scalar, producing invalid YAML (plain scalar) or silently absorbing the hint into the description string (quoted scalar). This reproduces #3991 for the case #3996 didn't cover: bundled core commands have no argument-hint in their source frontmatter, so the structural apply_argument_hint path is a no-op and this raw-text fallback is what actually runs. Skip every continuation line of the description scalar (anything more indented than the key itself) before inserting, so the new key always lands after the whole scalar ends rather than in the middle of it. Fixes #4044 * fix(claude): also skip unindented blank lines in description scalar PyYAML serializes an embedded paragraph break ("\n\n") inside a quoted description as unindented blank lines, not indented continuation lines. inject_argument_hint only skipped indented lines, so it still inserted argument-hint mid-scalar for multi-paragraph descriptions, reproducing the #4044 failure modes. Skip blank lines too, and add a regression test for the multi-paragraph case. |
||
|
|
6aa9431b24 |
Add Command Code integration to spec-kit (#4019)
* Add Command Code integration to spec-kit Adds `command-code` as a built-in skills-based integration so Spec Kit can be installed into Command Code. Command Code loads agent skills from `.commandcode/skills/speckit-<name>/SKILL.md` and invokes them in chat as `$speckit-<command>`. - New `CommandCodeIntegration` (SkillsIntegration) writing to `.commandcode/skills/`; declared multi-install safe (static, isolated agent root). - Register in `_register_builtins()` and the integration catalog. - Add `command-code` to `DOLLAR_SKILLS_AGENTS` so next-steps guidance renders `$speckit-*` invocations. - Tests: reuse `SkillsIntegrationTests` mixin plus a dollar-invocation next-steps test; registry completeness updated. - Docs: README and docs/reference/integrations.md (supported agents + multi-install-safe table). Co-authored-by: CommandCodeBot <noreply@commandcode.ai> Assisted-by: Command Code (autonomous) * Fix issue template agent lists to include command-code The runtime AGENT_CONFIG now includes command-code, but the GitHub issue templates and the consistency test's expected key list were not updated, failing test_issue_template_agent_lists_match_runtime_integrations. Co-authored-by: CommandCodeBot <noreply@commandcode.ai> Assisted-by: Command Code (autonomous) --------- Co-authored-by: CommandCodeBot <noreply@commandcode.ai> |
||
|
|
1b3695bfb3 |
fix(workflows): strip a resolved condition before the true/false check (#3883)
evaluate_condition() special-cases the strings "false"/"true" so that
`condition: "false"` behaves as a boolean, but it matches with
`result.lower()` and never strips.
The most common way a *string* reaches a condition is captured command
output, and the shell step stores stdout verbatim
(steps/shell/__init__.py:67 `"stdout": proc.stdout`). So `run: echo false`
resolves to "false\n", which matches neither branch and falls through to
`bool("false\n")` -> True:
'false' -> False
'false\n' -> True <-- bug
'false\r\n' -> True <-- bug
' false' -> True <-- bug
An `if` step therefore takes its `then` branch on a step that printed
"false", and `while`/`do-while` keep dispatching their body.
A workflow author cannot work around it: the registered filters are
default/join/map/contains/from_json — there is no `trim`.
`InitStep._resolve_bool` and both catalog readers already strip before
matching boolean text. `bool(result)` still sees the raw string, so no
non-boolean text changes truthiness.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
3451a21277 |
fix(workflows): guard a non-string overlay edit 'operation' (#3881)
`_parse_edit` reads `operation` straight from hand-edited YAML and then
does `if operation not in VALID_OPERATIONS`. `VALID_OPERATIONS` is a
frozenset, so that membership test hashes the value — and an unhashable
one raises:
operation={'insert_after': 'a'} -> TypeError: unhashable type: 'dict'
operation=['insert_after'] -> TypeError: unhashable type: 'list'
`validate_overlay_yaml`'s docstring promises "validation never raises",
and nothing upstream catches TypeError (layer_sources wraps only
YAMLError/OSError/UnicodeDecodeError; _commands catches only ValueError),
so the CLI dies with a raw traceback instead of reporting the error.
The trigger is an ordinary authoring mistake: nesting the recommended
shorthand form under the explicit key.
Every other field in the same function is isinstance-guarded first
(`anchor`, `step`, `step["id"]`); `operation` was the outlier. Check the
type first and return the message the function already uses for
`operation: None` / `operation: 7`.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
2df78f33fb |
Fix bug-test Python dependency provisioning (#4030)
* fix: provision Python test deps for bug-test workflow * test: anchor bug-test workflow domain assertions Address CodeQL py/incomplete-url-substring-sanitization alerts (14-17) by anchoring the PyPI domain assertions to their structural context: the `network.allowed` YAML list items in the source and the quoted JSON entries in the compiled lock. This defeats the incomplete-URL-substring pattern and strengthens the test to confirm the domains are real allowlist entries rather than incidental substrings. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5 * fix: provision test deps without creating a project lock Replace `uv sync --extra test` with `uv pip install --system -e ".[test]"` in the bug-test provisioning step. `uv sync` writes a root `uv.lock` (and `.venv`) into the working tree. This repository intentionally has no `uv.lock`/`[tool.uv]` (uv.lock is gitignored), so the sync produced an untracked lockfile before the agent checks out the fix ref in Step 2. `uv pip install` installs the test extra into the runner's Python without generating a project lock, keeping the working tree clean before the fix checkout. The editable install means the agent's `python3 -m pytest` runs against the checked-out fix code. Recompiled the lock and updated the assertions accordingly. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5 * chore(workflows): sync gh-aw action-pin metadata to latest across all workflows Dependabot bumps the third-party action `uses:` pins (and header comments) directly, but does not update gh-aw's own metadata: the per-file `gh-aw-manifest` JSON blob and the shared `.github/aw/actions-lock.json` pin cache. As a result the executing pins were already uniform and current (checkout v7.0.1, setup-node v7.0.0) while the manifest/cache metadata still recorded checkout v6.0.3 / setup-node v6.4.0. This is a latent downgrade hazard: a plain `gh aw compile` reads the stale cache and can silently revert the `uses:` lines back to the older pins, undoing Dependabot's bumps and breaking lockstep. Sync all four pin surfaces (uses / header comment / manifest / cache) to the current pins so every workflow agrees and a future recompile is a no-op: - actions-lock.json: checkout v6.0.3 -> v7.0.1, setup-node v6.4.0 -> v7.0.0, and add the setup-python v7.0.0 + setup-uv v9.0.0 entries now used by bug-test. - gh-aw-manifest blobs in the 5 non-bug-test lock files: checkout + setup-node bumped to match their own uses lines (bug-test was already current). No workflow body changes; only pin metadata. `uses:` pins are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5 Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised) --------- Co-authored-by: root <kinsonnee@gmail.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b54442f-ccc5-4be1-a05c-b360889670e5 |
||
|
|
36da77f864 |
fix(bundle): escape Rich markup in bundle CLI error and status output (#4023)
* fix(bundle): escape Rich markup in bundle CLI error and status output
`specify bundle`'s `_fail` helper interpolated its message straight into
`err_console.print`, which has Rich markup enabled. Every caller passes
`str(exc)` from a `BundlerError`, and those messages embed untrusted data
-- including the command's own argument -- so a `[...]` in it was parsed
as a style tag.
Balanced tags were silently swallowed; an unbalanced closer raised
`MarkupError`, which replaced the error message with a traceback and left
the output completely empty. Three commands crashed on user input alone,
with no project state required:
specify bundle catalog add 'ssh://ex[/red]ample.com/c.json'
specify bundle catalog remove 'no[/red]such'
specify bundle update 'no[/red]such'
`bundle validate` had the same failure on both branches: its errors echo
`requires.speckit_version`, and its warnings echo component ids, which are
not charset-validated -- so a structurally *valid* manifest crashed on the
success path too.
Fixed centrally in `_fail`, plus the remaining raw interpolations: the
`validate` warning/error/success lines, the install overlap and plan
warnings, the install/update/remove/catalog-add confirmations, the
`catalog list` id/url, and the `bundle init` project path.
Regression tests cover the four crashing error paths (parametrized) and
both `validate` branches; all six fail without this change.
Assisted-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(bundle): escape markup in `bundle list` records and `bundle build` output path
Review follow-up: two raw interpolations the first sweep missed, both on
success paths rather than error paths.
`bundle_list` rendered `record.bundle_id`, `record.version` and
`record.installed_at` unescaped. `InstalledBundleRecord.from_dict` only
requires non-empty strings for the first two and applies no charset check to
any of them, so a records file that *loads cleanly* still crashed the command
that displays it — confirmed as
`MarkupError: closing tag '[/red]' at position 12 doesn't match any open tag`.
`bundle_build` echoed `result.artifact_path` twice in its success line.
Brackets are legal in a directory name, so a bracketed `--output` built the
artifact and then misreported it: the work is already on disk when the
markup is consumed, so the line names a path that does not exist.
Re-scanned every `{...}` interpolation in the module to confirm nothing else
remains: the rest are either `BundlerError` messages that funnel through the
already-escaped `_fail`, `_format_component` output escaped at its call site
(:293), ints, or hardcoded enum `.value`s.
Two regression tests. The list case uses the unbalanced-closer form that
raises outright. The build case deliberately uses `[bold]` instead: `/` is a
path separator on Windows, so `dist[/red]out` becomes the directory
`dist[\red]out` and the fixture stops testing what it claims — the
silent-swallow form keeps it portable while still asserting the reported path
matches what was written. Verified both fail against 1d2184d.
tests/contract/test_bundle_cli.py -> 42 passed. tests/contract
tests/integration tests/unit -> 364 passed, 6 skipped, 5 failed; the 5 are
the pre-existing `*_refuses_symlinked_*` tests needing symlink privileges on
Windows, unchanged from main. ruff check passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
1a44a6aa08 |
fix(presets): skip an unreadable restore source in preset remove (#4020)
* fix(presets): skip an unreadable restore source in `preset remove` `_unregister_skills_in_dir` restores each preset-owned SKILL.md from a core command template or an extension source. Both of those reads were bare `read_text(encoding="utf-8")` calls, so a project-owned override in `.specify/templates/commands/` that exists but cannot be read or decoded raised a raw `UnicodeDecodeError`/`OSError` straight out of `PresetManager.remove()`, which has no handler for it — `specify preset remove` dies with a traceback. Every other failure in this loop degrades with `continue`: an unsafe registry name, a missing skill subdirectory, a foreign owner. Sibling reads of the very same directory are already guarded — `_infer_legacy_skill_ provenance` and `_delete_agent_preset_skills` both wrap their SKILL.md read in `except (OSError, UnicodeDecodeError): continue`, and the read inside `_substitute_core_template` was just given the same boundary in #3961. The two restore reads were the remaining gap. `continue` is the right recovery here rather than falling through: the `else` branch below removes the skill outright, so treating an unreadable source as "no source" would delete a user's skill at exactly the moment its replacement cannot be generated. Skipping leaves the skill in place and keeps it out of the returned `mutated_names`, so callers don't record a restore that never happened. Two regression tests, one per exception arm: a non-UTF-8 core template, and a mocked `PermissionError` so the `OSError` half is also covered under privileged CI where permission bits aren't enforced. Both assert the skill survives untouched and is not reported as mutated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(presets): warn when a skill keeps preset content after a failed restore Review follow-up on two points. Surface the skipped restore. Skipping is still the correct recovery — the alternative branch deletes the skill — but it was silent, and it is a partial removal: `remove()` goes on to delete the preset directory and the registry entry, while this `SKILL.md` keeps the removed preset's content, and leaving the name out of `mutated_names` also keeps it out of reconciliation, so nothing retries it. Both arms now emit a warning naming the skill, the unreadable source, and the exception, and pointing at the re-run that refreshes it once the file is fixed. `warnings.warn` matches how the surrounding code reports non-fatal degradation (the reconciliation failures in `remove()`/`install_from_directory`, the unreadable core template in `_substitute_core_template` from #3961). Cover the extension arm. A skill backed by an installed extension never reaches the core-template read, so the two branches can regress independently and both prior tests exercised only the core one. `test_unregister_skills_in_dir_unreadable_extension_source_skips` installs an extension whose command file is non-UTF-8 and asserts the skill survives byte-for-byte and is absent from `mutated_names`. Verified it raises the raw `UnicodeDecodeError` against unpatched source. The two existing tests now assert the warning via `pytest.warns` so dropping it fails the suite. pytest tests/test_presets.py -> 583 passed, 2 skipped, 7 failed; the 7 are the pre-existing Windows symlink tests that need elevation, unchanged from main. ruff check passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
11e3176fd1 |
fix(extensions): reject duplicate provides.templates/scripts names (#4016)
The resolver returns the first entry matching a declared name, so a later duplicate within provides.templates or provides.scripts was silently unreachable while still counted by ExtensionManifest properties. Reject duplicates at manifest-validation time instead. Also clarify EXTENSION-DEVELOPMENT-GUIDE.md's provides section: hooks and events are top-level manifest fields, not provides sub-fields, so the "at least one of ..." wording doesn't imply they can be nested under provides. |
||
|
|
16cfab7724 |
feat(presets): resolve constitution templates at command time (#3984)
* feat(presets): resolve constitutions at command time Gate install-time constitution materialization behind the constitution-sync preset while preserving one-time init seeding and authored-file safeguards. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84 * fix(presets): emit composed template content Add a machine-readable preset resolve mode backed by PresetResolver.resolve_content and require the constitution command to consume it. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84 * fix(presets): unify runtime template composition Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84 * fix(presets): secure runtime template resolution Align runtime resolution across script variants, validate registry path components, and honor canonical extension ordering and convention paths. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(presets): align runtime priority semantics Normalize and tie-break preset priorities consistently across script variants, and preserve template bytes when Python materializes generated files. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(presets): stop at effective template base Avoid parsing irrelevant lower layers once resolution reaches a replace base, and decode raw bytes so Python preserves source line endings. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(presets): align extension template resolution Support root-level extension templates across runtime resolvers, fail safely when Bash cannot parse an extension registry, and validate requested templates in every prerequisite output mode. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(presets): resolve dotted command identifiers Route safe dotted names through command resolution, correct traversal coverage, and make Windows CI text decoding explicit. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Avoid orphan feature directories on template errors Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Align malformed preset manifest handling Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * Complete runtime resolver parity Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * Fail closed on resolver input errors Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * Force UTF-8 and full manifest validation Force UTF-8 decoding for registry and manifest reads in the Bash and PowerShell embedded-Python parsers so resolution no longer depends on the process locale, and validate every manifest template entry's required fields, type, and strategy consistent with the canonical PresetManifest. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * Fail closed on empty manifests and corrupt registries Reject manifests missing the provides/templates sections or declaring an empty template list in all three runtime resolvers, matching the canonical PresetManifest which treats those as invalid instead of silently degrading a composing layer to a convention `replace` lookup. Make a corrupt or unreadable extension registry fail closed in Bash, PowerShell, and Python instead of swallowing the error and treating every on-disk extension directory as unregistered-and-enabled, which could activate a disabled extension. Read the preset and extension registries as explicit UTF-8 in the PowerShell resolver so priority/enabled-state decoding no longer depends on the process code page under Windows PowerShell 5.1. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * fix(presets): fail closed when extension registry is not a regular file The Bash and Python resolvers used is_file()/`-f` to gate reading the extension `.registry`, which returns false for a directory or a broken symlink at that path. In those cases the resolvers treated the registry as absent and scanned every on-disk extension directory as unregistered and enabled — a fail-open path. Detect any filesystem entry at the registry path (including broken symlinks) and reject unless it is a readable regular file. PowerShell now rejects a non-leaf entry explicitly for parity. Adds directory- and broken-symlink parity regressions. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * fix(presets): fail closed on corrupt registry in canonical resolver and PowerShell Two remaining fail-open paths for an invalid extension registry: - The canonical PresetResolver enumerated extensions through ExtensionRegistry, whose _load() normalizes a corrupt or unreadable registry to an empty mapping. The directory scan then admitted every on-disk extension directory as unregistered-and-enabled, so a corrupt registry could still supply constitution content at init and through constitution-sync materialization. Add a non-invasive is_corrupt() probe (recovery behavior for install/enable/disable is unchanged) and raise from _get_all_extensions_by_priority() when the registry exists but is invalid. _load() now also recovers from OSError/UnicodeDecodeError so a directory or unreadable registry no longer crashes construction. - The PowerShell resolver gated the registry read with Test-Path, which returns false for a dangling symlink on Windows, letting a broken .registry symlink bypass the guard and enable every on-disk extension. Detect the entry via directory enumeration (which observes a broken symlink) and reject it unless it is a readable regular file. Adds canonical corrupt/directory-registry regressions and extends the broken-symlink parity test to PowerShell. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * fix(presets): detect dangling registry symlink in ExtensionRegistry.is_corrupt is_corrupt() gated on Path.exists(), which follows symlinks and returns False for a dangling .registry symlink — so the canonical PresetResolver treated it as an absent registry and fell back to scanning every on-disk extension directory as unregistered-and-enabled, reopening the fail-open path this guard closes. Detect lexical existence with os.path.lexists and require a regular file before parsing, so a broken symlink (or directory) is reported corrupt and resolution fails closed. Adds a canonical broken-symlink regression alongside the directory case. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84 Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c |
||
|
|
1a60d1b6b8 |
[bug-fix] Fix preset-wrap-drops-argument-hint: inherit argument-hint from core template (#3996)
* Fix preset-wrap-drops-argument-hint: inherit argument-hint from core Apply the remediation from the bug assessment on issue #3991. Extend the inheritance allowlist in _register_skills and _compose_layers to include 'argument-hint', so wrap-strategy presets that omit this key will inherit it from the core template rather than silently dropping it and risking its value being leaked into description. Refs #3991 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(presets): guard wrap argument-hint inheritance for unmapped command The existing regression test for #3991 wraps `speckit.specify`, whose stem is in Claude's ARGUMENT_HINTS map. The string-injection fallback in post_process_skill_content re-adds argument-hint even when wrap composition drops it, so that test passes with or without the inheritance fix and does not actually guard the regression. Add a parallel test that wraps an extension-like command (`speckit.myfeature`) absent from ARGUMENT_HINTS, so the wrap-composition inheritance is the only path that can carry argument-hint into the SKILL.md. This test fails without the fix and passes with it. Refs #3991 Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 970babe2-48cd-4c41-adae-0282d879a9ce --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Copilot-Session: 970babe2-48cd-4c41-adae-0282d879a9ce |
||
|
|
684b3d8e05 |
feat(extensions): accept provides.templates and provides.scripts in manifest (#4012)
* feat(extensions): accept provides.templates and provides.scripts in manifest Extensions could only formally declare commands under `provides` (plus config/hooks/events); templates and scripts shipped by an extension were picked up purely by filename convention, with no id, description, or metadata. Add optional `provides.templates` and `provides.scripts` sections to the extension manifest schema, mirroring the preset template shape minus an authorable `strategy` (extension artifacts always resolve as replace, so a present `strategy` key is now a validation error rather than a silently accepted no-op). ExtensionManifest gains `templates`/`scripts` properties so tooling can enumerate an extension's declared artifacts directly from the manifest. An extension may now satisfy the "must provide something" rule with only a template or script, not just a command/hook/event. Addresses the manifest-schema portion of #4010; resolver authoritative-vs-convention precedence for these new sections is left for a follow-up. * fix(presets): wire extension-declared templates/scripts into resolver collect_all_layers only consulted ExtensionManifest for command resolution, leaving provides.templates/.scripts purely decorative -- a declared entry whose file didn't sit at the conventional path was validated but never resolved. Extend the existing manifest-fallback branch to cover template_type "template" and "script" the same way it already does "command": convention lookup first, manifest lookup as fallback so undeclared on-disk files keep resolving unchanged. * fix(presets): make extension manifest lookup authoritative over convention Copilot review on #4012 found the manifest-declared template/script lookup was gated on convention lookup missing first, so a stale conventional file could shadow a declared entry at a non-conventional path, and resolve() never consulted the manifest at all (only collect_all_layers() did). Add a shared _extension_manifest_declared_template() helper and check it before convention-based lookup in both resolve() and collect_all_layers(), mirroring the preset manifest precedence. Also update EXTENSION-DEVELOPMENT-GUIDE.md, which still claimed provides only supports commands and required a command or hook. * fix(presets): stop resolving symlinks in extension manifest candidate path _extension_manifest_declared_template() resolved ext_dir/rel_path before returning it, which follows symlinks in ext_dir's ancestors (e.g. macOS's symlinked tmp dir) and diverges from the unresolved paths convention-based lookup returns for the same directory. Resolve only for the traversal containment check; return the unresolved candidate. Fixes the 4 CI test failures across all OS/Python matrix jobs on #4012. |
||
|
|
247abbf5e4 |
fix(presets): treat an unreadable core template as missing (#3961)
* fix(presets): treat an unreadable core template as missing _substitute_core_template() read the resolved core template with a bare read_text(), so one corrupted project-owned override in .specify/templates/commands/ crashed the whole wrap-strategy command registration with a raw UnicodeDecodeError. Both callers (CommandRegistrar.register_pack and _register_commands) are unguarded here, even though register_pack already skips an unreadable preset source with a warning a few lines above the call. Treat an unreadable core template like a missing one — warn and return the body unchanged with empty frontmatter — matching the function's documented no-core contract. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: assert the unreadable-core warning instead of suppressing it Review follow-up: use pytest.warns so removing or changing the promised warning fails the test. Assisted-by: GitHub Copilot (model: claude-fable-5, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |