Skip to content

fix(workflows): stop offering a condition correction that inverts it - #4230

Merged
mnriem merged 10 commits into
github:mainfrom
ntdatt812:fix/condition-correction-that-inverts
Aug 21, 2026
Merged

fix(workflows): stop offering a condition correction that inverts it#4230
mnriem merged 10 commits into
github:mainfrom
ntdatt812:fix/condition-correction-that-inverts

Conversation

@ntdatt812

Copy link
Copy Markdown
Contributor

Follow-up to #4182. That PR merged while this last fix was in flight — by about a minute — so the defect below is still live on main.

Problem

format_condition_correction wraps whatever it is handed. That is right for a formatter, but the three step validators advertise its output as a paste-ready correction, and for two inputs it cannot repair the suggestion is actively harmful.

I measured what pasting each suggestion does rather than reasoning about it:

condition validator reports correction it suggests pasting that gives
" " always true "{{ }}" False
{{ inputs.name == 'abc always true "{{ inputs.name == 'abc }}" False

Both invert the condition rather than repair it:

  • 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 reads as the false keyword before bool() is ever reached.

So an author who follows the advice trades an always-true condition for an always-false one, having been told it was the fix.

Change

format_condition_remediation builds the advice, and the three validators call it instead of assembling the sentence themselves. It offers a correction only when wrapping would genuinely repair the input, and otherwise names the fault — the same call already made for condition_has_malformed_expression_block, which deliberately offers no suggestion.

'   '                    -> ... There is no expression here to wrap: use the literal
                             true or false, since an empty '{{ }}' block evaluates to
                             the empty string and would silently invert the condition.

{{ inputs.name == 'abc   -> ... Close the unbalanced quote first: wrapping it as
                             written leaves the quote open, so the raw-close fallback
                             evaluates a truncated comparison rather than the one
                             written, and its result can silently invert the condition.

inputs.count > 100       -> ... Wrap the expression: "{{ inputs.count > 100 }}".

_has_unbalanced_quote uses the same left-to-right scan as _find_block_close and _strip_stray_delimiters, so "inside a string" means one thing throughout the module.

Note on one of my own claims

My first draft of the unbalanced-quote message said the wrapped form "stays always true". The new test failed and showed it returns Falseevaluate_condition recognises the residual "False" as the keyword before coercion. The message and docstring say inverted, which is what the measurement shows. Flagging it because the distinction is the whole point of the change.

Verification — Python 3.11, on this branch off main

tests/unit + tests/test_workflows.py   1233 passed  (main: 1216)

The 22 failures in that run are identical with and without this commit — all TestWorkflowCliAlignment symlink tests, which need elevation on Windows. I ran the suite against unmodified main to confirm that rather than assume it.

Mutation-checked: removing either gate fails exactly the 9 new parametrised cases and nothing else, so the fixtures pin this defect rather than passing incidentally.

Credit to the Copilot reviewer on #4182 — it raised this as a suppressed comment there and was right.

…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Centralizes condition-remediation advice to avoid suggesting known condition inversions.

Changes:

  • Adds guarded remediation for blank and unbalanced-quote conditions.
  • Updates all conditional step validators.
  • Adds regression tests for unsafe corrections.
Show a summary per file
File Description
src/specify_cli/workflows/expressions.py Adds remediation logic and quote scanning.
src/specify_cli/workflows/steps/if_then/__init__.py Uses centralized remediation.
src/specify_cli/workflows/steps/while_loop/__init__.py Uses centralized remediation.
src/specify_cli/workflows/steps/do_while/__init__.py Uses centralized remediation.
tests/unit/test_condition_expression_block.py Covers correction inversions and quote detection.

Review details

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Suppressed comments (2)

src/specify_cli/workflows/expressions.py:930

  • The quote-balance check is not enough to establish that wrapping repairs the condition. For example, inputs.name == has a nonempty, quote-balanced core, so this still advertises "{{ inputs.name == }}"; the original is truthy text, but the suggested form resolves the missing RHS to None and evaluates the comparison as False. The paste-ready correction therefore still silently inverts malformed inputs. Validate the core for incomplete operators/brackets before offering a correction, or withhold the correction when its syntax cannot be established.
    if _has_unbalanced_quote(core):

src/specify_cli/workflows/expressions.py:935

  • The wrapped form does not use the raw-close fallback: because it starts and ends with {{ }}, _is_single_expression routes it through the typed fast path despite the open quote. The message gives users an incorrect diagnosis; it should state that wrapping leaves a malformed expression whose result can invert the condition.
            "Close the unbalanced quote first: wrapping it as written leaves the "
            "quote open, so the raw-close fallback evaluates a truncated comparison "
            "rather than the one written, and its result can silently invert the "
            "condition instead of repairing it."
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread src/specify_cli/workflows/expressions.py Outdated

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address Copilot feedback. Thanks for all the great work!

…ir 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.
@ntdatt812

Copy link
Copy Markdown
Contributor Author

Both suppressed findings were correct. Fixed in 6944920.

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

Same harm as the two cases this PR already covered — the author is told the condition is always true and hands back an always-false one.

2. The message named the wrong mechanism. I said the wrapped form goes through the raw-close fallback. Measured:

_is_single_expression("{{ inputs.name == 'abc }}")  ->  True

So it takes the typed fast path instead. The diagnosis was wrong even though the conclusion — that the result can invert the condition — was right.

What changed

Enumerating broken shapes was the mistake; it is why this needed three rounds. _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 that 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 exactly what made my previous two versions wrong, twice, so they no longer do it.

The test that matters

test_every_offered_correction_is_a_complete_expression states the property rather than the shapes: whatever is advertised as paste-ready must survive both validators. A new malformed shape is caught by the invariant now, not by adding another fixture row.

Verification — 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 failures identical before and after — the TestWorkflowCliAlignment symlink tests that need elevation on Windows, confirmed by running against the stashed tree.

One mutation initially escaped, and that is worth reporting

Removing "<" from _COMPARISON_OPERATORS left all tests green. The operator test parametrised over the constant it was checking, so shrinking the constant shrank the test — the same can't-fail-when-it-matters shape this whole PR exists to reject, in my own test. It is hard-coded now and that mutation fails.

Final mutation results, each gate against its own cases: operand gate → 12 failures, bracket gate → 3, dropped operator → 1.

@mnriem
mnriem requested a balanced review from Copilot August 20, 2026 13:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread src/specify_cli/workflows/expressions.py Outdated
Comment thread src/specify_cli/workflows/expressions.py Outdated
@mnriem

mnriem commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

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.
@ntdatt812

Copy link
Copy Markdown
Contributor Author

@mnriem — done, in 24c71a6. Both live Copilot findings were correct.

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 offered   True -> False
and inputs.ready          ->  correction offered   True -> False

Worth naming plainly: that is the same defect this PR's parent commit fixed one level up — stopping at the first match — reintroduced inside the gate written 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 at all. _WORD_OPERATORS is derived from _COMPARISON_OPERATORS and matched against both ends without that space.

2. _has_unbalanced_bracket counted depth, so mismatched types cancelled out:

inputs.f(]   ->  correction offered   True -> False

It now tracks opener types on a stack and rejects a non-matching closer.

On the third thread

The docstring one is marked outdated and refers to the text before 6944920. The line it points at now reads:

Each branch names something observable about the text itself, deliberately not the interpolator path it will take: two earlier versions of this message asserted an internal route — the raw-close fallback — and were wrong, because _is_single_expression accepts the wrapped form and sends it down the typed fast path instead.

So it does not attribute the inversion to the raw-close fallback; it records that earlier versions did and states the actual path, which matches what the reviewer described. I left it as the explanation for why the messages avoid naming internal routes. Happy to reword if you would rather it not mention the fallback at all.

Verification — 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 failures identical before and after — the TestWorkflowCliAlignment symlink tests needing elevation on Windows, confirmed against the stashed tree.

Mutation-checked: depth-only brackets → 9 failures, first-occurrence-only → 3, dropping the end-of-core word scan → 16.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

src/specify_cli/workflows/expressions.py:1017

  • reason is None does not establish that wrapping repairs the condition; it only excludes four structural shapes. For example, inputs.items | length passes these gates and is advertised as paste-ready, but the evaluator raises ValueError("unknown filter 'length'") for the wrapped form. The existing he said "hi"\nthen left fixture is also classified as correctable even though wrapping makes it resolve to None, changing the original truthy string to false. Please validate the core against the evaluator's actual expression grammar/registered filters (or conservatively withhold a correction when that cannot be established) before returning Wrap the expression.
    reason = _wrapping_would_not_repair(core)
    if reason is None:
        return "Wrap the expression: " + format_condition_correction(condition) + "."
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@mnriem
mnriem self-requested a review August 20, 2026 14:26
…ting 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.
mnriem
mnriem previously approved these changes Aug 20, 2026
@ntdatt812

Copy link
Copy Markdown
Contributor Author

That was the strongest point raised on this PR, and it was right. Addressed in 6c8ca2c.

reason is None only excluded four structural shapes, and structural shapes cannot establish that wrapping yields a working expression. Both examples reproduce:

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, 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, so the validator cannot bless a filter the evaluator will reject.
  • _reads_as_prose reports a core that is several bare terms with nothing 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.

The fixture was mine, and it was wrong in two ways

he said "hi"\nthen left was only in the offered-correction set because I built that fixture as TRICKY_CONDITIONS + [...]. TRICKY_CONDITIONS exists to exercise the formatter's quoting and deliberately contains prose — reusing it asserted something it was never meant to claim. The list is explicit now, with the tricky-quoting entries that really are expressions carried over by hand, so adding prose to that fixture can no longer widen this invariant.

inputs.tags | length > 0 was also mine, and length is not a registered filter. It is join(',') now. My own fixture was asserting a correction the evaluator would have crashed on.

Verification — 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 failures identical before and after — the TestWorkflowCliAlignment symlink tests needing elevation on Windows, confirmed against the stashed tree.

Mutation-checked: dropping either new gate fails 3 cases and nothing else.

One thing worth your call, @mnriem

This is the fourth round in which the suggested correction — not the validation itself — was wrong in a new way. The rejection of never-evaluated conditions has been solid since #4182; it is the paste-ready suggestion that keeps needing another gate.

I think the gates are now in the right place, because the last two read the evaluator's own tables rather than restating structure. But if you would rather not carry that surface at all, dropping the suggestion and keeping only the diagnosis would delete four helpers and remove this whole class of defect permanently. Happy to cut it down if that is the call — it is your maintenance burden, not mine.

@mnriem
mnriem requested a balanced review from Copilot August 20, 2026 14:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread src/specify_cli/workflows/expressions.py Outdated
… 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.
@ntdatt812

Copy link
Copy Markdown
Contributor Author

Thanks for the approval @mnriem — one more push after it, because the Copilot comment that arrived two minutes later was correct and I would rather not leave it. a61ff6c.

Two more shapes got through:

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", wrapped form resolves to None,
                         True -> False

That is the fifth shape in four rounds, which is the argument against enumerating shapes. So this commit stops:

  • _evaluator_rejects runs the core through _evaluate_simple_expression against a probe namespace and returns the evaluator's own error. Any filter under an unknown name or in an unsupported form is now reported by the code that will actually run. _unregistered_filter, which restated the filter table, is deleted.
  • _is_not_a_bare_path covers the part 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 deleted too.

The probe namespace resolves roots but not leaves on purpose. I tried the permissive version first, and a namespace that answers every lookup also answers inputs.count+1 — it hides the exact shape the probe exists to expose.

Net: two helpers fewer, and no restatement of the evaluator's tables anywhere in the gate.

Verification — 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 failures identical before and after — the TestWorkflowCliAlignment symlink tests needing elevation on Windows, confirmed against the stashed tree.

Mutation-checked: dropping either check fails 6 cases and nothing else.

Since this landed after your approval, please re-approve or tell me to revert to 6c8ca2c — I did not want to sit on a reproducible defect, but I also do not want to slip a change past a review you had already given.

@mnriem
mnriem requested a balanced review from Copilot August 20, 2026 15:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (1)

src/specify_cli/workflows/expressions.py:1046

  • Add the second blank line before this top-level function; otherwise the repository's Ruff CI check reports E305.
def _wrapping_would_not_repair(core: str) -> str | None:
  • Files reviewed: 5/5 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread src/specify_cli/workflows/expressions.py Outdated
Comment thread src/specify_cli/workflows/expressions.py Outdated
Comment thread src/specify_cli/workflows/expressions.py
@mnriem

mnriem commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

… 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.
@mnriem

mnriem commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Lets get this one through the review and then if you can describe what specifically would make it easier / cleaner / faster then lets address that by describing it in an issue so we can asses it? Hope that is OK? Thanks!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 5/5 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread src/specify_cli/workflows/expressions.py
Comment thread src/specify_cli/workflows/expressions.py Outdated
Comment thread src/specify_cli/workflows/expressions.py Outdated
@mnriem

mnriem commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

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.
@ntdatt812

Copy link
Copy Markdown
Contributor Author

@mnriem — two of the three are fixed in 967fa66. The third I could not fix without doing the thing this PR has spent seven rounds backing away from, so I want to put a direct question to you rather than keep going.

Fixed: my checks 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; first/last-character equality is not that.
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. _looks_numeric and _is_literal now use the evaluator's own tests verbatim, and the root segment is matched without stripping an index off it first.

Not fixed: inputs.tags | join(5)

join always raises for a non-string separator, so this is a real hole. But it 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 — that is the fix from two rounds ago, which stopped steps.emit.output.stdout | from_json being wrongly rejected.

The two cannot be separated from the message:

join: expected a string separator, got int        <- the author's literal
join: expected a string separator, got NoneType   <- my probe's placeholder

They differ only in a type name the probe may have supplied. I tried keying on whether the filter segments reference a namespace root, but that misclassifies steps.emit.output.stdout | from_json, where the probe-caused error is about the filter input rather than its argument. Catching join(5) means encoding each filter's argument types in the validator.

The question

Eight rounds now, every one of them about the suggested correction. The validation itself has been correct since #4182 merged; what keeps breaking is the paste-ready string offered alongside it, because getting it right means reimplementing the evaluator's grammar and now its type rules inside a validator.

So, plainly — which do you want?

  1. Keep the suggestion. I encode the filter argument types next, and we accept that this surface tracks the evaluator by hand and will drift again.
  2. Drop the suggestion. The error keeps naming the fault and stops handing back a string to paste. That deletes five helpers and the whole class of defect, permanently. format_condition_correction stays exported and tested for anyone who wants it.

I have offered (2) three times without wanting to remove a shipped behaviour unilaterally, so I would rather you decide. Either way I will do it in one pass.

Verification — Python 3.11

tests/unit/test_condition_expression_block.py      267 passed  (was 253)
tests/unit + tests/test_workflows.py              1367 passed  (was 1353)
ruff check src/specify_cli/workflows/expressions.py   5 errors — the ones already on main

22 failures identical before and after — the TestWorkflowCliAlignment symlink tests needing elevation on Windows.

Mutation-checked: restoring the bare float() fails 2 cases; restoring the first/last-character quote test fails 3.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

src/specify_cli/workflows/expressions.py:1100

  • This leaf check omits the evaluator's supported list-literal grammar (expressions.py:582-595, covered by tests/test_workflows.py:392-419). Consequently a valid bare condition such as inputs.tag in ['x', 'y'] is classified as an unresolvable name and denied the correction even though wrapping it is a complete, evaluable expression. Recurse through list elements just as the evaluator does.
    if _is_literal(stripped):
        return None

    segments = _split_top_level(stripped, ".")
    if not _PATH_SEGMENT.match(segments[0].strip()):

src/specify_cli/workflows/expressions.py:1080

  • Filter arguments are skipped here, so malformed inputs can still receive a paste-ready correction. For example, inputs.tags | join(bogus) passes this check; the probe then ignores join's NoneType argument error, but the suggested wrapped condition always raises because bogus is not a namespace root. Inspect each parsed filter argument with the same operand validation before offering a correction.
    if _find_top_level(stripped, "|") != -1:
        segments = _split_top_level(stripped, "|")
        return _unresolvable_term(segments[0])
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

…and 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.
@ntdatt812

Copy link
Copy Markdown
Contributor Author

@mnriem Understood on both counts — through review first, and the broader design point as a separate issue. I will write that one up once this lands.

Both suppressed Copilot findings are addressed in a5970cf. I checked each against the evaluator before changing anything, because they point in opposite directions and one of them was described at the wrong entry point.

List literals — the correction was withheld from a valid condition.

_unresolvable_term resolved ['x', 'y'] as a path, so:

inputs.tag in ['x', 'y']
  -> No correction is offered because "['x', 'y']" is not a name the evaluator can resolve
  but {{ inputs.tag in ['x', 'y'] }} evaluates to True

_evaluate_simple_expression treats [...] as a term and recurses into the elements, so the leaf check now does the same, including the empty-segment skip that makes [1, 2,] be [1, 2] rather than [1, 2, None].

The finding named format_condition_correction, which always wraps whatever it is handed; the gate is _wrapping_would_not_repair, reached through format_condition_remediation. Same defect, different entry point.

Filter arguments — a correction was offered for something that raises.

inputs.tags | join(bogus)
  -> Wrap the expression: "{{ inputs.tags | join(bogus) }}"
  but that raises ValueError: join: expected a string separator, got NoneType

_apply_filter evaluates the argument with _evaluate_simple_expression like any other operand. The check now parses it with the same (\w+)\((.+)\) pattern _apply_filter uses, and a form that pattern does not match is left to the evaluator probe rather than guessed at here.

One correction to the finding's reasoning, since it affects the fix: not every unresolvable argument raises. default tolerates the None, so inputs.count | default(bogus) evaluates fine and is withheld on the same policy that already withholds bogus == 'x' — which also evaluates fine. I split the tests accordingly rather than asserting a crash that does not happen; my first version of that test asserted it and failed.

Verification

Windows, Python 3.13. Every case is asserted against what the evaluator does with the wrapped form, not against a restatement of the check, so a check that drifts from the evaluator fails the test rather than agreeing with itself.

tests/unit/test_condition_expression_block.py          280 passed
tests/test_extensions.py + test_workflows.py
  + test_condition_expression_block.py                 1718 passed, 22 failed

Those 22 are pre-existing and platform-bound. Measured on both sides — reverting this PR's two files to their parent revision gives the identical 22 failures, diff clean, with 1705 passed against 1718 here. The +13 is exactly the cases added. They are the same symlink and bash-parity classes that account for all 168 failures in the full local run (7005 passed): unelevated Windows cannot create symlinks, and the *_python_parity files shell out to the bash scripts.

Mutation-checked, after confirming each edit actually applied:

skip every filter argument      -> 3 failed  (join(bogus), map(bogus), default(bogus))
list literals fall through      -> 5 failed  (all five list-literal cases)

Nothing else moves in either case, so the new cases pin these two behaviours rather than passing incidentally.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

src/specify_cli/workflows/expressions.py:1146

  • Not every root is a mapping: StepContext.item is Any, and fan-out assigns each item value directly, so an item may itself be a list. The evaluator therefore supports item[0], but this root-membership check reports it as unknown and withholds a valid correction such as "{{ item[0] == 'x' }}". Strip the numeric index before checking the root, while retaining the index rejection for the roots that are always mappings.
    # The root itself is never indexed: `_build_namespace` hands back mappings, so
    # `_resolve_dot_path` returns None for `inputs[0]` however the index is written.
    if segments[0].strip() not in _NAMESPACE_ROOTS:
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread src/specify_cli/workflows/expressions.py
@mnriem

mnriem commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

`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.
@ntdatt812

Copy link
Copy Markdown
Contributor Author

Addressed in b0c7704. This one is a correction to something I got wrong earlier in this branch, not just a gap.

Two rounds ago I replaced an index-stripping root check with a plain membership test, on the reasoning that _build_namespace hands back mappings so an indexed root always resolves to None. That is true of four roots and false of the fifth. StepContext.item is Any, and a fan-out assigns the item value itself, so when the item is a list _resolve_dot_path takes its index branch and resolves:

item[0] == 'x'   item = ["x", "y"]   ->  evaluates True    correction offered: False   <- wrong
inputs[0]        always a mapping    ->  evaluates False   correction offered: False   <- right
steps[1]         always a mapping    ->  evaluates False   correction offered: False   <- right
fan_in[0]        always a mapping    ->  evaluates False   correction offered: False   <- right

So the index is stripped for item alone rather than for roots in general, and there is a paired test for the other four so the narrowing cannot later widen into "any indexed root". Both directions are asserted against what the evaluator returns, not against the check.

I should have caught this when I wrote that line — the comment I put above it asserted a property of all five roots that I had only verified for inputs.

Verification

tests/unit/test_condition_expression_block.py           286 passed
tests/test_extensions.py + test_workflows.py
  + test_condition_expression_block.py                  1724 passed, 22 failed

Same 22 as the unpatched tree, diff clean against the baseline I measured for the previous push — the pre-existing symlink and bash-parity classes on unelevated Windows. 1705 -> 1724 passed is exactly the 19 cases added across both pushes.

Mutation-checked, after confirming the edit applied:

never strip the index   ->  2 failed  (item[0], item[1])

The always-mapping cases stay green under that mutation, which is the point — they do not depend on the strip, so they still guard the other side.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (1)

src/specify_cli/workflows/expressions.py:1025

  • This suppresses deterministic evaluator failures as well as probe-placeholder failures. For example, format_condition_remediation("inputs.tags | join(5)") offers "{{ inputs.tags | join(5) }}", but the numeric separator makes the wrapped expression always raise ValueError (the evaluator contract is pinned at tests/test_workflows.py:615-624). The same occurs for map(5) and invalid literal from_json inputs, so these are still advertised as paste-ready corrections even though wrapping cannot repair them. Please distinguish errors caused by unresolved probe values from errors caused by literal operands/arguments and withhold the correction for the latter.
    except ValueError as exc:
        message = str(exc)
        # Every error _apply_filter raises about the filter *expression* quotes the
        # segment back as `got '| ...'`. Its value errors instead name the type they
        # received, which under a probe is the placeholder, not anything the author
        # wrote -- treating those as rejections withheld corrections from valid
        # conditions such as `steps.emit.output.stdout | from_json`.
        if "got '| " in message:
            return message.split(":", 1)[0]
    except Exception:  # noqa: BLE001 - probe values, not the author's text
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@mnriem
mnriem merged commit 2dddaa5 into github:main Aug 21, 2026
14 checks passed
@mnriem

mnriem commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Thank you!

mnriem pushed a commit that referenced this pull request Sep 10, 2026
…4460)

* refactor(workflows): let the evaluator report its own leaves (#4274)

_unresolvable_term answered one question -- does every operand in this
condition resolve to something? -- by walking the expression itself:
filters, then or/and/not, then comparisons, then list literals, down to
the leaves. That walk was a second implementation of the parsing in
_evaluate_simple_expression, kept in step with it by hand.

Two helpers existed only to restate rules the evaluator already had.
_looks_numeric mirrored the float()-only-when-a-dot-is-present rule
because a bare float() accepts 1e3 and the evaluator does not.
_is_literal mirrored the matching-close-is-the-final-character string
test because startswith/endswith accepts 'a' 'b' and the evaluator does
not. Both docstrings said "mirror the evaluator exactly", which is the
tell: when the two drift nothing breaks loudly, the gate just answers
wrongly, and the wrong answer is a paste-ready correction that inverts a
condition.

Seven of the nine findings in #4230 were the same defect wearing
different clothes -- the gate disagreeing with the evaluator about where
the operands are. Each round fixed one shape. Nothing stopped a tenth.

_evaluate_simple_expression has exactly one place where a substring stops
being grammar and becomes a name to resolve: its final line,
_resolve_dot_path. Literals return before it; operands, filter arguments
and list elements all arrive there by construction. Record the leaf
there, behind a ContextVar that is None outside a probe, and the gate
applies namespace rules to that list instead of re-deriving it. It now
contains no grammar at all.

Two properties this rests on, both asserted rather than assumed:

  * or/and are not short-circuited -- both sides are evaluated and only
    then combined -- so a leaf is recorded whatever the other side is
    worth. If that ever changes the gate would go quietly blind, so
    there is a test for it.

  * A probe run can raise on its own placeholder values. The leaves seen
    before that point are real, so they are kept rather than discarded;
    discarding them would lose `bogus` in `inputs.tags | join(bogus)`,
    which an earlier round of #4230 had to add by hand.

expressions.py is 109 lines lighter and 84 heavier. All 336 existing
tests pass unchanged, including the 20 cases of
test_operands_must_be_literals_or_known_paths that took eight rounds to
get right. test_literal_test_mirrors_the_evaluator tested the mirror, so
it becomes test_literal_handling_comes_from_the_evaluator and asserts the
same knowledge about 1e3 and 'a' 'b' through the gate instead.

Four mutations, each killed by the tests that should kill it -- removing
the leaf report alone turns 38 red. ruff 0.15.0 clean.

* refactor(workflows): let _resolve_dot_path define the indexed segment

The gate no longer restates the operator grammar, but it still restated
the shape of a path segment: _PATH_SEGMENT and an inline fullmatch both
described the index form that _resolve_dot_path matches with its own
regex. Three copies of one rule, kept in step by hand -- the same drift
this refactor set out to remove, one layer down.

Name the form once as _INDEXED_SEGMENT beside _resolve_dot_path and have
the gate ask it. Behaviour is unchanged: the regex is copied verbatim.
What changes is that widening indexing now reaches the gate for free.

* test(workflows): pin that the gate reads the evaluator's definitions

Two regression tests for the property this refactor is for, both of which
a second copy of the grammar in the gate would break while every existing
test stayed green:

- widening _INDEXED_SEGMENT alone reaches the gate (the negative-index
  shape from #4416)
- when the evaluator stops treating something as a leaf, the gate stops
  checking it, with no gate edit (the grouped-operand shape from #4417)

Both were checked by reintroducing the drift: giving the gate its own
segment regex again fails the first with the real message rather than an
import error.

* fix(workflows): keep collecting leaves after a probe error

The refactor stopped the leaf walk at the first exception a probe value
raised, so every leaf further along the chain was lost. That is the one
thing the collection exists to report, and it was a step backwards from
the hand-written walk this PR replaces:

  inputs.blob | from_json | contains(bogus)
    origin/main            reports 'bogus'
    this PR before the fix MISSED
    this PR after the fix  reports 'bogus'

from_json receives the probe placeholder mapping and raises; the walk ended
there and contains(bogus) was never reached.

Carry on past a failing filter while the sink is armed. _apply_filter
evaluates a filter argument before it can raise on the value, so the failing
segment's own leaves are already recorded; a fresh placeholder goes into the
next filter, matching what the probe namespace hands out.

Scoped to the probe: the sink is armed only by _collect_leaves, and
_evaluator_rejects runs its own probe without it, so a mis-wired filter is
still rejected and a real evaluation still raises rather than quietly
returning the unfiltered value.
KSchlobohm added a commit to KSchlobohm/spec-kit that referenced this pull request Sep 11, 2026
* [extension] Update Spec Kit Schedule extension to v0.7.4 (#4498)

* Update Spec Kit Schedule extension to v0.7.4

Update schedule extension submitted by @jfranc38:\n- extensions/catalog.community.json (version, download_url, metadata)\n- docs/community/extensions.md community extensions table\n\nCloses #4457\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>\nAssisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous)

* Apply suggestion from @KSchlobohm

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Ken Schlobohm <keschlob@microsoft.com>

* chore: shorten stale timeline to 60 days stale, 30 days to close (#4503)

Update the stale workflow so issues and PRs are marked stale after 60
days of inactivity and closed 30 days later (90 days total), down from
150/30. Messages updated to match.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* docs(core): SPECIFY_FEATURE sets the feature label, not the feature directory (#3786)

* docs(core): SPECIFY_FEATURE sets the feature label, not the feature directory

docs/reference/core.md told users to set SPECIFY_FEATURE "to the feature
directory name ... to work on a specific feature when not using Git branches".
That does not work: SPECIFY_FEATURE only feeds get_current_branch /
Get-CurrentBranch (the feature *label*). The directory comes from
SPECIFY_FEATURE_DIRECTORY or .specify/feature.json.

Verified on main with the real helper -- with ONLY SPECIFY_FEATURE set:

    $ SPECIFY_FEATURE=001-photo-albums ... get_feature_paths
    ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run
           the specify command to create .specify/feature.json.
    exit=1

    $ SPECIFY_FEATURE_DIRECTORY=specs/001-photo-albums ... get_feature_paths
    FEATURE_DIR    -> <resolved>
    CURRENT_BRANCH -> 001-photo-albums

The code's own error message points at the other variable, and the doc's own
"Two resolution axes" note directly below already says the feature is selected
by SPECIFY_FEATURE_DIRECTORY / .specify/feature.json -- so the table row
contradicted both the code and the paragraph under it.

Describe what the variable actually does, note that /speckit.specify and the
Git extension normally set it, and point at the directory axis. Docs only.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(core): describe SPECIFY_FEATURE as an explicit label override

The row still misstated when and how the label is applied:

* "when there is no Git branch context" — get_current_branch and
  Get-CurrentBranch never inspect Git at all. They return the variable
  verbatim when set, and otherwise fall back to the basename of the
  resolved feature directory.
* "Normally set for you by /speckit.specify" — specify.md persists
  feature_directory to .specify/feature.json and never sets this
  variable.
* The Bash and Python feature scripts can only *print* a commented
  export hint, because a child process cannot change its parent's
  environment. The PowerShell scripts do assign $env:SPECIFY_FEATURE,
  but only reach the caller when run inside the current session.

Rewrite it as an explicit user-set label override, and distinguish the
printed persistence hint from actually setting the caller's environment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(core): attribute the label fallback to get_feature_paths, not get_current_branch

The row said the label "falls back to the basename of the resolved feature
directory" when SPECIFY_FEATURE is unset, and attributed that to
get_current_branch / Get-CurrentBranch. Those helpers return an EMPTY
string when the variable is unset — scripts/bash/common.sh:87 says so
outright ("Return empty to signal 'unknown'") and scripts/python/common.py
is `return os.environ.get("SPECIFY_FEATURE", "")`.

The basename substitution happens later, in get_feature_paths /
Get-FeaturePaths, after the feature directory has been resolved
(scripts/python/common.py:168-169). Measured:

  get_current_branch (unset)       -> []
  get_current_branch (set)         -> [my-label]
  get_feature_paths CURRENT_BRANCH -> [001-photo-albums]

So a caller invoking the named helpers directly does not get the fallback.
Distinguish the two behaviours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(core): name the PowerShell helper Get-FeaturePathsEnv

The doc cited `Get-FeaturePaths`, which does not exist. The PowerShell twin of
`get_feature_paths` is `Get-FeaturePathsEnv`
(scripts/powershell/common.ps1:152); there is no bare `Get-FeaturePaths`
anywhere in the tree.

Verified every function name the entry cites now resolves against the scripts:
get_current_branch, Get-CurrentBranch, get_feature_paths, Get-FeaturePathsEnv.
The quoted resolution error is verbatim from scripts/bash/common.sh:206.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(events): cap stdin in the generated dispatcher, not just the CLI command (#4337)

* fix(events): cap stdin in the generated dispatcher, not just the CLI command

The #3857 fix capped stdin at 1 MiB in `specify event run`
(src/specify_cli/commands/event.py), but that command is not the code path
native hooks actually invoke. Every installed integration writes a
self-contained `.specify/events.py` dispatcher (the
`_EVENTS_DISPATCHER_TEMPLATE` string in src/specify_cli/events.py) that
native hook configs call directly, and its `main()` did:

    payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"

with no size cap at all — the exact DoS #3857 was meant to close, wide open
on the primary invocation path. `specify event run` is a secondary/manual
entry point; the generated dispatcher is what actually runs on every
session_start/pre_tool_use/etc. hook fire in real usage.

Fix: apply the same byte-capped read (from the binary buffer, so the cap
counts encoded bytes rather than decoded characters — matching the
just-merged fix for the CLI command) inside the dispatcher template, so
every newly-installed or refreshed dispatcher enforces the limit.

## Test plan
- Added 3 tests in tests/integrations/test_events.py::TestCommandRunner:
  an oversized payload exits 1 with the limit message instead of running
  unbounded, a multibyte payload (~300k emoji, ~1.14 MiB UTF-8 but only
  300k characters) is still rejected by the byte-based cap, and a normal
  under-the-cap payload still reaches the handler script unchanged.
- Verified both new failing-without-fix tests via test-the-test (stashed
  the src fix): the oversized-payload test failed because the dispatcher
  silently accepted the full payload and returned "not found" instead of
  exiting 1 with the limit message — reproducing the exact bug.
- Ran the full tests/integrations/test_events.py suite (124/128 pass; the
  remaining 4 are the pre-existing Windows symlink-elevation failures
  unrelated to this change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJHJ2dHP2RVCNncHqN8Qm9

* fix(events): pin utf-8 encoding on the handler subprocess in both dispatch paths

Addresses Copilot review feedback on PR #4337:

Both `_run_inline` (the generated dispatcher's stdlib fallback) and
`resolve_and_run_event_command` (the delegated/CLI-native path) decode
stdin explicitly as utf-8, then pass that string to the handler via
`subprocess.run(..., text=True)` with no explicit `encoding=`. Without
one, `text=True` re-encodes the payload for the child's stdin using
`locale.getpreferredencoding()` — on Windows that's commonly the ANSI
codepage, not UTF-8 — so a non-ASCII payload byte (e.g. "é") reaches
the handler as the wrong byte, corrupting JSON for handlers that
expect UTF-8. Pin `encoding="utf-8"` on both subprocess.run calls so
the decode and re-encode agree.

Also rewrote `test_dispatcher_underlimit_stdin_still_runs` (previously
skipped entirely on Windows via a POSIX-only `sh` handler) to use a
cross-platform Python handler and assert byte-for-byte fidelity of a
non-ASCII payload, and added
test_dispatcher_inline_fallback_preserves_non_ascii_payload, which
forces the `_run_inline` fallback (never reached in a dev environment
where specify_cli is importable, since the dispatcher always delegates
first) so that path's fix is independently verified too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhR6g8xT8at5pPMhkrC3e2

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* chore: release 1.0.6, begin 1.0.7.dev0 development (#4511)

* chore: bump version to 1.0.6

* chore: begin 1.0.7.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* chore: refresh bug-assess workflow with gh-aw v0.88.7 (#4497)

* chore: refresh bug-assess workflow with gh-aw v0.88.7

Regenerate bug-assess and update compiler-managed metadata.

Assisted-by: GitHub Copilot (model: GPT-6 Astra, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: restore immutable bug-assess setup action pin

Regenerate with gh-aw v0.88.7 and working GitHub authentication so setup references resolve to the release commit.

Assisted-by: GitHub Copilot (model: GPT-6 Astra, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: remove duplicate workflow attributes rule

Restore .gitattributes to its pre-PR contents while retaining the existing generated-workflow attributes.

Assisted-by: GitHub Copilot (model: GPT-6 Astra, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: exempt repository maintenance workflows from PR throttle (#4499)

* docs: exempt repository maintenance workflows from PR throttle

Keep contributor confirmation requirements while allowing verified repository-owned gh-aw maintenance runs on behalf of CODEOWNERS to create their configured PR outputs.

Assisted-by: GitHub Copilot (model: GPT-6 Astra, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: simplify maintenance workflow confirmation exception

Limit the policy change to one sentence per document; retain existing review prioritization and author-over-cap guidance.

Assisted-by: GitHub Copilot (model: GPT-6 Astra, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: add JSON output to preset and extension lists (#4218)

* feat(cli): add JSON output for installed lists

* fix(cli): preserve installed source provenance in JSON

Preserve valid catalog provenance in installed preset and extension JSON output while retaining the local fallback for missing, legacy, unknown, and malformed records.

Carry raw registry source metadata through healthy and corrupt manager records, whitelist the public kind/catalog shape in the shared adapter, and document and test the contract without changing provenance producers.

* fix(cli): persist catalog provenance across install paths

Propagate normalized catalog names through preset and extension install,
init, bundler refresh, archive, and update paths while preserving local
fallbacks and deterministic JSON ordering.

* fix(cli): serialize installed-list usage errors as JSON

Handle parse-time Click usage errors for preset and extension list when
the raw --json flag is present, preserving stderr-only output and exit 2
in either flag order. Document and test the contract.

* fix(cli): support Typer's vendored usage errors

Catch parse failures from Typer's vendored Click implementation while
retaining a narrow fallback for pre-vendoring Typer releases. Normalize
ANSI only in human-output assertions.

* fix: count extension hook events in JSON output

Use one event-key count for the legacy extension list record and public JSON response. Update the multi-entry regression to preserve that contract.

---------

Co-authored-by: root <kinsonnee@gmail.com>

* Add Product Definition as Code (PDaC) extension to community catalog (#4514)

Add pdac extension submitted by @juangcarmona to the community catalog and documentation.\n\nCloses #4454\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>\nAssisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* [preset] Add Secure Development Assurance Governance preset (#4513)

* Add Secure Development Assurance Governance preset to community catalog

Add secure-development-assurance-governance preset submitted by @hindermath to:

- presets/catalog.community.json (alphabetical order)

- docs/community/presets.md community presets table

Closes #4455

Assisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Apply suggestion from @KSchlobohm

* Update Secure Development Assurance Governance entry

Co-authored-by: Copilot Autofix powered by AI <175728472+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: Ken Schlobohm <keschlob@microsoft.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* refactor(workflows): let the evaluator report its own leaves (#4274) (#4460)

* refactor(workflows): let the evaluator report its own leaves (#4274)

_unresolvable_term answered one question -- does every operand in this
condition resolve to something? -- by walking the expression itself:
filters, then or/and/not, then comparisons, then list literals, down to
the leaves. That walk was a second implementation of the parsing in
_evaluate_simple_expression, kept in step with it by hand.

Two helpers existed only to restate rules the evaluator already had.
_looks_numeric mirrored the float()-only-when-a-dot-is-present rule
because a bare float() accepts 1e3 and the evaluator does not.
_is_literal mirrored the matching-close-is-the-final-character string
test because startswith/endswith accepts 'a' 'b' and the evaluator does
not. Both docstrings said "mirror the evaluator exactly", which is the
tell: when the two drift nothing breaks loudly, the gate just answers
wrongly, and the wrong answer is a paste-ready correction that inverts a
condition.

Seven of the nine findings in #4230 were the same defect wearing
different clothes -- the gate disagreeing with the evaluator about where
the operands are. Each round fixed one shape. Nothing stopped a tenth.

_evaluate_simple_expression has exactly one place where a substring stops
being grammar and becomes a name to resolve: its final line,
_resolve_dot_path. Literals return before it; operands, filter arguments
and list elements all arrive there by construction. Record the leaf
there, behind a ContextVar that is None outside a probe, and the gate
applies namespace rules to that list instead of re-deriving it. It now
contains no grammar at all.

Two properties this rests on, both asserted rather than assumed:

  * or/and are not short-circuited -- both sides are evaluated and only
    then combined -- so a leaf is recorded whatever the other side is
    worth. If that ever changes the gate would go quietly blind, so
    there is a test for it.

  * A probe run can raise on its own placeholder values. The leaves seen
    before that point are real, so they are kept rather than discarded;
    discarding them would lose `bogus` in `inputs.tags | join(bogus)`,
    which an earlier round of #4230 had to add by hand.

expressions.py is 109 lines lighter and 84 heavier. All 336 existing
tests pass unchanged, including the 20 cases of
test_operands_must_be_literals_or_known_paths that took eight rounds to
get right. test_literal_test_mirrors_the_evaluator tested the mirror, so
it becomes test_literal_handling_comes_from_the_evaluator and asserts the
same knowledge about 1e3 and 'a' 'b' through the gate instead.

Four mutations, each killed by the tests that should kill it -- removing
the leaf report alone turns 38 red. ruff 0.15.0 clean.

* refactor(workflows): let _resolve_dot_path define the indexed segment

The gate no longer restates the operator grammar, but it still restated
the shape of a path segment: _PATH_SEGMENT and an inline fullmatch both
described the index form that _resolve_dot_path matches with its own
regex. Three copies of one rule, kept in step by hand -- the same drift
this refactor set out to remove, one layer down.

Name the form once as _INDEXED_SEGMENT beside _resolve_dot_path and have
the gate ask it. Behaviour is unchanged: the regex is copied verbatim.
What changes is that widening indexing now reaches the gate for free.

* test(workflows): pin that the gate reads the evaluator's definitions

Two regression tests for the property this refactor is for, both of which
a second copy of the grammar in the gate would break while every existing
test stayed green:

- widening _INDEXED_SEGMENT alone reaches the gate (the negative-index
  shape from #4416)
- when the evaluator stops treating something as a leaf, the gate stops
  checking it, with no gate edit (the grouped-operand shape from #4417)

Both were checked by reintroducing the drift: giving the gate its own
segment regex again fails the first with the real message rather than an
import error.

* fix(workflows): keep collecting leaves after a probe error

The refactor stopped the leaf walk at the first exception a probe value
raised, so every leaf further along the chain was lost. That is the one
thing the collection exists to report, and it was a step backwards from
the hand-written walk this PR replaces:

  inputs.blob | from_json | contains(bogus)
    origin/main            reports 'bogus'
    this PR before the fix MISSED
    this PR after the fix  reports 'bogus'

from_json receives the probe placeholder mapping and raises; the walk ended
there and contains(bogus) was never reached.

Carry on past a failing filter while the sink is armed. _apply_filter
evaluates a filter argument before it can raise on the value, so the failing
segment's own leaves are already recorded; a fresh placeholder goes into the
next filter, matching what the probe namespace hands out.

Scoped to the probe: the sink is armed only by _collect_leaves, and
_evaluator_rejects runs its own probe without it, so a mis-wired filter is
still rejected and a real evaluation still raises rather than quietly
returning the unfiltered value.

* Fix catalog-latest-url-bypass: require tag-pinned catalog download URLs (#4194)

* fix: require tag-pinned catalog download URLs (#4185)

Reject floating releases/latest URLs in the community catalog agent
workflows and require the URL tag to match the submitted version.

Refs #4185

Assisted-by: Cursor Grok 4.6 (supervised)
Signed-off-by: shaurya2k06 <shaurya2k06@gmail.com>

* test: assert both catalog tag forms together

Separate substring checks for vX.Y.Z and X.Y.Z were not independent.

Refs #4185

Assisted-by: Cursor Grok 4.6 (supervised)
Signed-off-by: shaurya2k06 <shaurya2k06@gmail.com>

* fix: allow scoped catalog tags and same-repo download URLs

Keep tag-pinned URLs but accept suffixes like aide-v1.0.0, require
download_url to match the submitted repository, and treat sha256 as
optional follow-up rather than a hard catalog gate.

Refs #4185

Assisted-by: Cursor Grok 4.6 (supervised)
Signed-off-by: shaurya2k06 <shaurya2k06@gmail.com>

* fix: keep collecting catalog validation failures after a latest URL

Skip the HTTP check for a floating releases/latest URL without aborting
the rest of Step 2.

Refs #4185

Assisted-by: Cursor Grok 4.6 (supervised)
Signed-off-by: shaurya2k06 <shaurya2k06@gmail.com>

* fix: gate catalog SHA checks on URL pinning

Keep archive fetching and optional hash verification behind successful URL pinning checks.

Refs #4185

Assisted-by: Codex (model: GPT-5, autonomous)
Signed-off-by: shaurya2k06 <shaurya2k06@gmail.com>

---------

Signed-off-by: shaurya2k06 <shaurya2k06@gmail.com>

* [extension] Add GitHub Issue Triage extension to community catalog (#4539)

* Add GitHub Issue Triage extension to community catalog

Add gh-triage extension submitted by @arrrrny to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #4339

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Assisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous)

* Apply suggestion from @KSchlobohm

---------

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: Ken Schlobohm <keschlob@microsoft.com>

* fix(workflows): harden community submission workflows (#4510)

* fix(workflows): harden community submission workflows per security review

Address two hardening suggestions from a GitHub Security Lab PVR against the
community catalog submission workflows (bundle, extension, preset):

1. Validate the sanitized event snapshot instead of the live issue. Step 1 now
   consumes ${{ steps.sanitized.outputs.text }} — the documented gh-aw sanitized
   full-context output — rather than instructing the agent to re-fetch the issue
   body, which could change between the maintainer applying the label and the
   agent reading it. This ties validation to the triggering submission.

2. Make threat detection block safe outputs. Add
   safe-outputs.threat-detection.continue-on-error: false so a detected threat
   fails the run instead of only warning and still producing a draft PR.

Recompiled the three .lock.yml files with gh-aw v0.79.8.

(Finding #2, create-pull-request allowed-files, is already implemented on all
three workflows upstream, so no change was needed there.)

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(workflows): preserve action pins and add fail-closed regression test

Address PR review feedback:

- Restore actions/checkout (v7.0.1) and actions/setup-node (v7.0.0) pins in the
  three regenerated lock files. A local recompile had resolved older cached
  pins; the lock files now differ from the base only by the intended snapshot
  and threat-detection changes.
- Add a regression test asserting each community submission workflow enables
  threat detection with continue-on-error: false in source and compiles to the
  fail-closed detection gate, so a later regeneration cannot silently restore
  warning-only behavior.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(workflows): drop sanitized-snapshot input, keep fail-closed detection

The regenerated sanitized snapshot (steps.sanitized.outputs.text) redacts any
HTTPS host absent from the workflow's allowed-domains list. These submission
workflows record off-allowlist URLs verbatim (extension homepage/documentation/
changelog, bundle required component catalogs, and the proposed catalog-entry
JSON), so a snapshot input would corrupt otherwise-valid submissions.

Revert Step 1 to reading the triggering issue and keep the separate maintainer
PR review as the control for issue edits. The fail-closed threat-detection
change (continue-on-error: false) and its regression test are retained.

Recompiled the three lock files; action pins and the pin database are unchanged
from the base.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix: require explicit refresh for bundle manifest changes (#4477)

* fix: reject bundle version changes during install

* fix: support explicit local bundle refresh

Assisted-by: OpenAI Codex (autonomous)

* fix: clarify catalog requirements for local bundle refresh

Exercise local manifest, directory, and ZIP refresh through the real extension installer with deterministic catalog artifacts. Preserve state on offline failure and verify the online retry refreshes the owned version.

Assisted-by: OpenAI Codex (model: GPT-6 Astra, autonomous)

* fix: require refresh for owned bundle component changes

Compare recorded component metadata with the requested plan before primitive operations. Reject changed pins, sources, preset options, and removals even when the bundle version is unchanged. Preserve idempotent installs, reordering, and additions; exercise refresh through lifecycle and real-installer CLI regressions.

Assisted-by: OpenAI Codex (autonomous)

* feat: add `specify artifact` introspection (#4305)

* Add deterministic contribution IDs and stack lookup IDs for resolved artifacts

Every command, template, script, and hook contribution returned by
preset and extension manifest surfaces now carries a computed opaque
identifier of the form {layer}:{sourceId}:{kind}:{name}, and every
resolved artifact-stack layer carries a matching lookupId derived from
the same recipe.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Identifiers are computed at read time from author-declared manifest
content only. No paths, timestamps, or file-content hashes contribute
to derivation, so identifiers are stable across machines, reinstalls,
and directory moves. Nothing is persisted to .specify/ or any cache.

Hooks that collide within a source on (eventName, command) get a
12-hex SHA-256 discriminator computed from the canonical JSON of the
entry's declared fields minus eventName/command. Two hook entries
with byte-identical remaining fields are rejected at manifest load
because there is no meaningful way to distinguish them.

The change is purely additive: all existing name-based resolution
behaviour is preserved, and no consumer keys off the new id or
lookupId fields.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous)

* feat: add `specify artifact` command exposing composition stacks as JSON

Adds a new `specify artifact` command group with two subcommands:

* `specify artifact list --json` — flat inventory of every command,
  template, and script SpecKit exposes for the current project. Each row
  carries a stable `id` (`{kind}:{name}`), an author-declared
  `name`, its `kind`, and a `description` string that is never
  omitted (empty string when the author declared none).

* `specify artifact info <name> --json` — the same row plus its full
  ordered composition `stack`: highest-priority contributor first, with
  `active` marking the winner `PresetResolver.resolve_content` would
  return and `hidden` marking rows shadowed by a higher-priority
  `replace`. Each stack entry carries a portable POSIX `manifestPath`
  (or `null` for the core baseline) and a `lookupId` from the
  contribution-id grammar so the output round-trips against
  `specify preset info` and `specify extension info`.

The two commands share one strict JSON error envelope on stderr
(`{ "error": "..." }`) with exit code 1 for the three logical errors
(unknown artifact, ambiguous artifact, not a Spec Kit project) and exit
code 2 for the "`--json` is required" usage error. stdout is always
empty on error, so the two streams stay independently parseable.

Implementation lives in a new `src/specify_cli/artifacts/` subpackage
that mirrors the existing `presets/` and `extensions/` layout — pure
logic in `__init__.py` and thin Typer wiring in `_commands.py`. The
subpackage reuses `PresetResolver.collect_all_layers` for the actual
composition math and only reshapes each layer into a `StackLayer` JSON
row, so `active` and `hidden` stay in lockstep with the resolver's
winner-selection logic.

Skills (`.github/skills/**/SKILL.md`) are intentionally excluded from
the inventory — they are integration-specific installation output, not a
shipped asset family. The command still surfaces the underlying command
that a skill was generated from.

Tests:

* `tests/test_artifact_command.py` — 32 tests: contract shape, sort
  order, empty-inventory behavior, kind-hint parsing, ambiguous-name
  error, unknown-artifact error, not-a-project error, skills exclusion,
  CLI wiring end-to-end (`--json` required, JSON envelope shape,
  stderr-only errors, empty stdout on error, UTF-8 with no BOM), and
  preset-replace hiding the core layer.

* `tests/test_artifact_command_parity.py` — 6 tests: `manifestPath`
  uses forward slashes on every OS and is never absolute, the `active`
  row corresponds to the resolver's actual winner, and the pretty-printed
  JSON has no trailing whitespace and ends in exactly one newline.

All 38 new tests pass. Full presets + extensions regression suite is
green modulo pre-existing Windows-symlink-privilege failures that
predate this branch.

Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4a40fb96-1bbe-4fb2-99d8-411170046cb0

* Potential fix for pull request finding 'Module is imported with 'import' and 'import from''

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* Potential fix for pull request finding 'Module is imported with 'import' and 'import from''

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* Potential fix for pull request finding 'Unused import'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* Project preset artifacts by entry type

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Represent project override artifact layers

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Preserve artifact JSON init-dir errors

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Canonicalize core script artifacts

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Fix artifact inventory resolver filtering

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Add resolver tests for single-runtime core scripts

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Cache artifact resolver lookups

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Handle artifact resolver failures

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Document artifact resolution error

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Include convention-based artifacts in inventory

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Restore legacy flat core script lookup

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Extend convention discovery to presets in artifact inventory

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Fix manifest path portability and export ArtifactResolutionError

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Bound artifact manifest search to project root

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Cover project-root artifact manifests

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Handle directory artifact manifest lookups

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Fall back to top-level preset name in artifact stacks

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Include project-local core artifacts in inventory

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Address inline review feedback on artifact resolver helpers

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Reuse manifest/registry APIs in artifact contribution enumeration

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Pass layer explicitly to _iter_pack_contributions instead of inferring from parent dir

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Fix core command namespacing and validate names for kind-scoped lookups

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Skip manifest contributions without a usable identifier

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Hoist test-local imports to module scope in artifact/assets tests

Assisted-by: GitHub Copilot (model: Claude Sonnet 4.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: resolve artifact inventory and validation review regressions

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* perf: avoid duplicate read in core command inventory

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: classify dotted override-only artifacts as commands

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: accept single-segment artifact commands

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: fail closed on corrupt artifact registries

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: trust inventory for artifact info lookups

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: validate registry before artifact info

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: resolve artifact description by layer precedence, not enumeration order

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: validate subdir before wheel bundle lookup in _locate_core_asset_dir

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: detect duplicate hooks after command canonicalization

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: reuse normalized hook entries for duplicate detection

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: align core command candidate ordering

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* test: cover manifest-backed artifact parity

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: align artifact IDs with resolver identity

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: skip invalid local artifact name components

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: filter invalid local artifact IDs from inventory

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: align artifact preset enumeration with resolver

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* test: remove tautological artifact tests and strengthen id assertion

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: preserve documented hook duplicate semantics

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: dedupe hook contributions last-wins

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* docs: clarify hook identifier deduplication

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* docs: remove hook discriminator references

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* style: space identifier declarations

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: address unresolved review feedback on PR #4305

- Validate preset-registry corruption in artifact catalog (fail closed with
  ArtifactResolutionError, mirroring the extension-registry check) and add
  ``PresetRegistry.is_corrupt`` in the shape of ``ExtensionRegistry.is_corrupt``.
- ``_locate_core_asset_dir`` now falls through to the source checkout when a
  wheel bundle is present but missing the requested family subdirectory,
  matching the "wheel, then source" fallback pattern used by the sibling
  bundled-extension/workflow/preset resolvers.
- Enforce the identifier component contract at the shared derivation
  boundary: ``derive_named_id`` / ``derive_hook_id`` now revalidate every
  input via ``validate_component`` so raw filesystem-derived names cannot
  produce non-round-trippable lookup ids.
- Overwrite ``eventName`` in hook contributions from the containing hook key
  instead of ``setdefault`` so author-supplied fields cannot contradict the
  derived ``name`` / ``id`` metadata.
- Manifest-declared preset and extension resolver layers now use the
  manifest's validated ``id:`` for the ``lookupId`` ``sourceId`` component,
  so the join to ``iter_contributions()`` stays direct when the installed
  directory was renamed. Convention-only contributions still fall back to
  the directory / registry key; directory identity is retained on the layer
  via ``source`` / ``extension_id`` / ``extension_dir``.
- Artifact catalog reuses the manifest's own contribution ``id`` verbatim
  when yielding declared contributions so it stays consistent with the
  resolver.
- Docs: clarify in ``docs/reference/presets.md`` and
  ``extensions/EXTENSION-API-REFERENCE.md`` that manifest contribution ``id``
  and resolver ``lookupId`` share the same grammar but only join directly
  when the installed directory matches the manifest-declared ``id:``.
- Restore the ``## File System Layout`` heading before the ``.specify/``
  tree in ``extensions/EXTENSION-API-REFERENCE.md`` and add it to the ToC.
- Use one consistent import style for ``specify_cli._assets`` in
  ``tests/test_assets.py`` (module import only) and update the existing
  test-suite entries whose behavior was locked to the resolver's old
  directory-key ``lookupId``.

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: keep on-disk preset/extension identity separate from lookupId

The resolver now emits manifest.id in the ``lookupId``'s ``sourceId``
component for manifest-declared preset and extension layers, so code that
had been extracting the on-disk directory name from ``lookupId`` (in
``_derive_manifest_path`` and ``_build_stack``) now points to the wrong
path when the installed directory was renamed.

Carry the directory identity as explicit ``preset_id`` / ``pack_dir`` keys
on preset layer dicts (extension layers already carried ``extension_id`` /
``extension_dir``). Update ``_derive_manifest_path`` and ``_build_stack``
to prefer those explicit keys before falling back to ``lookupId`` parsing,
so the display name and manifest path in the stack row keep tracking the
actual on-disk directory.

Extend the mismatch tests to lock down that ``presetId`` and
``manifestPath`` point to the renamed on-disk directory even when
``lookupId`` uses the manifest id.

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: remove stale lookupId parsing fallback and tighten malformed lookupId validation

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: route resolver core fallback through shared asset resolver, describe project overrides, document specify artifact

Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* refactor: drop redundant derive_named_id import-visibility assignment

Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: align artifact inventory and lookup ID validation

Address the latest review feedback for root-level legacy templates and unsupported lookup ID kinds.

Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* fix: fail closed on malformed artifact registries

Treat missing registry collection keys as corruption and map filesystem read failures to the artifact JSON error envelope.

Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* fix: preserve convention artifact descriptions

Use the existing artifact description extractors for convention-based preset and extension files.

Assisted-by: GitHub Copilot (model: GPT-5.6 Luna, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* fix: align artifact override resolution

Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Refactor artifact inventory candidates

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Address artifact inventory review

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Address artifact inventory review

Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Address artifact inventory review

Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* source-agnostic artifact IDs; built-in tier recognized by exclusion, never by name.

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: reject malformed artifact layer provenance

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Tighten artifact provenance handling

Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Refactor shared asset directory lookup

Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Document shared asset families

Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Avoid full artifact content scans

Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Clarify artifact resolution guard

Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Restore resolver core provenance

Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Reuse artifact inventory layers

Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Simplify preset resolve assertion

Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* Restore source-agnostic artifact provenance

Assisted-by: GitHub Copilot (model: GPT-5.4, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* artifact catalog: `id` is the source-agnostic round-trip key; `info` accepts `id`; docs and issue #4212 updated.

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: keep layer_kind_from_lookup_id and derive_hook_id in agreement on hook layers

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* artifact: reuse shared project resolver, rename handlers, dedupe validation

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: align artifact info existence and resolver naming

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* artifact: reuse PresetResolver.templates_dir in _project_core_asset_root

Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: guard stale registry entries in artifact convention discovery

Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: include stack in artifact list json

Assisted-by: GitHub Copilot (model: GPT-5 Codex, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* docs: document artifact list stack records

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* feat: add artifact layer source paths

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* docs: clarify artifact sourcePath provenance

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* refactor: clarify sourcePath derivation flow

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* refactor: document artifact source path fallback

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* refactor: expose registrar output path helper

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* refactor: centralize registrar skill output check

Assisted-by: GitHub Copilot (model: GPT-5.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* fix: only use materialized command output for the active stack row

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>

* docs(artifacts): cross-link contribution identifier grammar

Add a direct link from docs/reference/artifacts.md to the contribution-identifiers section of the extension API reference next to the existing presets.md link, so readers of the artifact CLI reference can find the id/lookupId grammar without re-deriving it here.

Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* fix(artifacts): enforce identifier grammar and gate manifestPath on declared contributions

Three related correctness fixes for the artifact-stack pipeline surfaced during PR #4305 review:

1. _identifier.py: add source_id_from_lookup_id() helper mirroring layer_kind_from_lookup_id, and enforce the project/'_' sentinel in derive_named_id (project layer requires source_id == '_'; preset/extension layers reject '_'). Swap the split(':', 2)[1] call in artifacts/__init__.py to use the new helper so consumers no longer parse identifier grammar directly.

2. presets/__init__.py: thread a manifest_declared flag through collect_all_layers so downstream consumers can distinguish manifest-declared contributions from convention-only fallbacks.

3. artifacts/__init__.py: _derive_manifest_path returns None when the layer is not manifest-declared, so a stack row for a convention-only contribution no longer falsely reports a manifestPath pointing at a manifest that does not declare it.

Tests: compact param-based coverage for source_id_from_lookup_id and derive_named_id sentinel rules; one preset + one extension test proving lookupId uses the manifest's validated id when it differs from the on-disk directory name; one end-to-end extension test proving a convention-only contribution reports manifestPath: null. Existing TestManifestPathPortability fixtures updated to set manifest_declared: True.

Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* chore(tests): remove trailing blank lines

Assisted-by: GitHub Copilot (autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* fix(presets): reuse parsed extension manifest identity

Carry the validated extension manifest ID out of the manifest-first resolution helper so collect_all_layers does not re-read the manifest and fall back to a directory-based lookupId after a transient second-read failure.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* fix(identifiers): enforce layer source sentinel when parsing

Share the project underscore sentinel rule across named and hook constructors and lookupId parsing so malformed project and provider provenance is rejected consistently.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* docs(identifiers): clarify built-in provenance contract

Document that built-in artifact layers omit lookupId and round-trip through their source-agnostic public kind:name ID, while project overrides retain a synthetic stack identity.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* fix(artifacts): preserve preset registry fallback

Remove the artifact-specific preset corruption guard and retain Spec Kit's existing behavior of treating malformed preset registry data as an empty registry. Keep extension registry validation unchanged.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* test(artifacts): drop preset corruption fallback coverage

Do not establish a new artifact-specific contract test for the preset registry's pre-existing malformed-data fallback.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* chore(changelog): remove manual unreleased entry

Leave release-note generation to the existing release workflow, which derives versioned changelog entries from commit subjects.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* docs(artifacts): clarify layer resolution semantics

Document that active reflects Spec Kit's existing layer precedence rather than successful content composition, and limit artifact resolution failures to errors encountered while collecting the stack.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* docs(artifacts): explain resolver reuse

Document why artifact inventory resolves each candidate through Spec Kit's existing single-artifact path and defers unmeasured shared caching.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* fix(presets): preserve legacy layer resolution

Keep filesystem-derived project, preset, and extension layers resolvable when legacy names cannot be represented by the contribution-ID grammar. Such layers omit lookupId while manifest-declared contributions remain strict.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* fix: preserve README convention resolution

Keep root-level README templates aligned with the existing resolver and artifact inventory instead of introducing a filename-specific exclusion.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* fix: preserve existing script resolution

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* refactor: isolate artifact provenance

Keep lookup identifiers and provenance projection inside the artifact catalog while restoring existing resolver, extension, hook, asset, registrar, and integration behavior.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* refactor: split artifact catalog modules

Separate artifact models, catalog inventory, and resolver stack projection while preserving the existing package API and command behavior.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* style: normalize artifact resolution EOF

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* fix: expose both override artifact kinds

Remove filename-based kind guessing for root project overrides and let the existing resolver validate both command and template candidates.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* docs: use Spec Kit product spelling

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* fix: confine artifact script references

Reject anchored, traversing, and symlink-escaping script references before artifact discovery reads files outside the selected script root.

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* test: cover composing stack visibility

Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: nicolehaugen <nicolehaugen@users.noreply.github.com>
Copilot-Session: 4a40fb96-1bbe-4fb2-99d8-411170046cb0
Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f

* [extension] Update MAQA — Multi-Agent & Quality Assurance extension to v0.3.1 (#4544)

* Update MAQA extension to v0.3.1

Update maqa extension submitted by @GenieRobot:
- extensions/catalog.community.json (version, download_url, metadata)
- docs/community/extensions.md community extensions table

Closes #4452

Assisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Decrease command count from 5 to 4

Co-authored-by: Copilot Autofix powered by AI <175728472+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: Ken Schlobohm <keschlob@microsoft.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Update DocGuard — CDD Enforcement extension to v0.34.9 (#4545)

Update docguard extension submitted by @raccioly:

- extensions/catalog.community.json (version, download_url, updated_at)

- docs/community/extensions.md (existing row already current)

Closes #4537

Assisted-by: GitHub Copilot (model: gpt-5.2-codex, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Signed-off-by: shaurya2k06 <shaurya2k06@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Noor ul ain <noor01mk@gmail.com>
Co-authored-by: WOLIKIMCHENG <35391914+WOLIKIMCHENG@users.noreply.github.com>
Co-authored-by: root <kinsonnee@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Nguyen Thanh Dat <ntdat812@gmail.com>
Co-authored-by: Shaurya Srivastava <104617579+Shaurya2k06@users.noreply.github.com>
Co-authored-by: RKS <rajesh.sharma@owasp.org>
Co-authored-by: nicolehaugen <nicolela@microsoft.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
Co-authored-by: nicolehaugen <nicolehaugen@users.noreply.github.com>
Copilot-Session: 4a40fb96-1bbe-4fb2-99d8-411170046cb0
Copilot-Session: 33a4ecee-211d-4be0-9842-7a433c5ada8f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants