Skip to content

feat(presets): materialize script composition chains at lifecycle time (#4551) - #4709

Open
Ashfaqbs wants to merge 14 commits into
github:mainfrom
Ashfaqbs:feat/script-continuation-dispatcher
Open

Ashfaqbs wants to merge 14 commits into
github:mainfrom
Ashfaqbs:feat/script-continuation-dispatcher

Conversation

@Ashfaqbs

@Ashfaqbs Ashfaqbs commented Sep 23, 2026 •

Copy link
Copy Markdown

Continues #4551. Per @mnriem's review, this is now a lifecycle-materialization design instead of the runtime-resolution one this PR originally shipped — see the pivot discussion below for the full rationale.

What changed in this revision

The runtime dispatcher (per-invocation resolution via a hidden CLI shell-out) is gone. The chain is now materialized to disk at preset-lifecycle time, mirroring how PresetManager._reconcile_script_chain()'s sibling machinery for commands already works:

  • PresetManager._reconcile_script_chain() now writes one fixed-path generated launcher file per composing layer under .specify/scripts/bash/: the topmost lands at the canonical <name>.sh agents actually invoke, each one's $CORE_SCRIPT points at the next-lower layer's fixed path, and the chain ends in a materialized copy of the base layer at <name>.speckit-core.sh. A single "replace"-strategy layer with no composition is still written verbatim with no intermediate files.
  • Install, remove, enable, disable, and set-priority all re-materialize now — PresetManager.reconcile_scripts_for_preset() is called from command_enable.py/command_disable.py/command_set_priority.py the same way reconcile_constitution() already is, closing the gap where those three commands previously did nothing for scripts at all.
  • PresetResolver.resolve_script_chain() is unchanged — same ordering/collection logic, only when it's called (materialization time, not invocation time) and what consumes its output (a file-writer, not a runtime shell-out) changed.

Dropped entirely: the hidden specify preset script-chain CLI command, the generated continuation-runner.sh, the SPECKIT_SCRIPT_CONTINUATION env protocol, the __main__.py fallback that existed only so the runtime dispatcher could resolve without specify on PATH, and the documented uvx one-time-install limitation (scripts now have no runtime dependency on specify being importable/on PATH at all).

Known limitation, carried forward rather than solved

A project-local override added directly to .specify/templates/overrides/scripts/ outside any lifecycle command is not auto-materialized — PresetManager.reconcile_all_script_chains() still needs an explicit call, the same gap already documented for that method's role in repairing a forced shared-infra refresh.

Evidence

  • TestScriptChainReconciliation in tests/specify_cli/presets/test_manager.py: single-layer verbatim writes, multi-layer launcher stubs, provenance-based cleanup when a chain shrinks back to nothing, symlink-safety guards extended to every generated path, and a new test proving enable/disable/set-priority now re-materialize immediately (previously a no-op gap for scripts).
  • tests/test_script_continuation_bash.py: real bash subprocess execution proving ordering (outer-before → inner-before → core → inner-after → outer-after), arg/exit-status propagation, and that a priority change only takes effect after an explicit reconcile call — the materialized-model inverse of the old runtime test's claim.
  • Full existing suite green: 8120 passed, 468 skipped (environment-gated, e.g. requires_bash where this sandbox's bare bash resolves to the WSL launcher over subprocess), 1 pre-existing unrelated failure deselected (test_setup_tasks_ps_core_template_resolved, verified failing identically on this branch before any of my changes via git stash).

Left for follow-up, pending your input

  • PowerShell and Python adapters. Same open question as before the pivot — this PR stays Bash-only.
  • Manifest schema for cross-runtime scripts. Unchanged from before: no way yet for a preset to declare .ps1/.py counterparts of a script name.

Disclosure per CONTRIBUTING.md: implemented with Claude Code, autonomous mode with human review of the diff and test results; the materialization design follows directly from mnriem's review comment on this PR.

…github#4551)

A "wrap" script's $CORE_SCRIPT reference previously had no runtime
resolution mechanism at all: resolve_content("script") composes the
full chain into a single spliced file, but nothing ever calls it in a
real code path, so preset/extension script composition has been dead
since it was added.

This adds the runtime side, scoped to Bash for this first increment:

- PresetResolver.resolve_script_chain(): returns the ordered file
  chain for a script name (highest priority down through the
  terminating "replace" layer), reusing collect_all_layers().
- `specify preset script-chain <name>` (hidden): prints that chain,
  one path per line, for the bash adapter to consume.
- scripts/bash/continuation-runner.sh: advances the chain one hop,
  execing into the next layer and threading the remainder via
  SPECKIT_SCRIPT_CONTINUATION so a further "wrap" layer's own
  $CORE_SCRIPT call continues correctly.
- PresetManager._reconcile_script_chain(): writes the project's
  canonical .specify/scripts/bash/<name>.sh — a verbatim copy when
  there's nothing to compose, or a fixed dispatcher stub when there
  is. The stub carries no stack-specific data, so it resolves the live
  chain fresh on every invocation and never needs rewriting again:
  install/remove (which change which layers exist) call it, but
  enable/disable/set-priority (which only reorder existing layers)
  don't need to.

Fixed a real bug while building the bash integration test: `specify`'s
stdout carries CRLF line endings on Windows, and bash's $(...) only
strips trailing newlines, not carriage returns, so an unstripped \r
was corrupting the exec path. Stripped at the source with `tr -d
'\r'` plus a defensive strip in the runner's own read loop.

PowerShell and Python adapters, and extending the preset manifest
schema so a `type: script` entry can declare per-language files
(today `file:` only supports .sh), are left for follow-up commits
pending confirmation on the schema shape.

Disclosure per CONTRIBUTING.md: implemented with Claude Code,
autonomous mode with human review of the diff and test results; the
design (continuation representation, dispatcher/runner split, why
scripts don't need commands' repeated reconciliation) follows directly
from the approach mnriem and I settled on in this issue's discussion.
@Ashfaqbs
Ashfaqbs requested a review from mnriem as a code owner September 23, 2026 17:02
@mnriem mnriem added the triage-can-wait Verdict: valid and in-scope but deprioritized; held behind the evidence gate label Sep 23, 2026
@mnriem
mnriem requested a balanced review from Copilot September 23, 2026 18:53

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.

Copilot review overview

🟡 Changes recommended

Multiple critical execution, lifecycle, compatibility, security, and test-suite issues remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 9 High severity · 3 Medium severity

Open (12)
What changed in this PR

Adds Bash runtime continuation dispatch for composable preset scripts.

Changes:

  • Adds runtime script-chain resolution and hidden CLI support.
  • Materializes Bash dispatchers and continuation runners.
  • Adds resolver, reconciliation, and end-to-end Bash tests.
File Review
tests/​test_script_continuation_bash.py Adds Bash execution, argument, status, and priority-order coverage.
tests/​test_presets.py Adds script-chain resolution and reconciliation tests.
src/​specify_cli/​presets/​command_resolve.py Critical: Update exact command-registration expectations. Nit: Add CLI tests for invalid names and empty chains.
src/​specify_cli/​presets/​__init__.py Critical: Fix non-executable layer invocation, built-in Bash base lookup, one-to-many reconciliation, missing runner installation, symlink-safe writes, re-scaffolding reconciliation, and project-local override materialization. Moderate: Avoid requiring a persistent/current specify executable, remove stale canonical scripts, and validate wrap layers.
scripts/​bash/​continuation-runner.sh Critical: Invoke continuation layers through Bash and update shared-infrastructure file expectations.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/bash/continuation-runner.sh Outdated
Comment thread scripts/bash/continuation-runner.sh Outdated
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/command_resolve.py Outdated
Comment thread src/specify_cli/presets/__init__.py Outdated
# newlines, not carriage returns — an unstripped one ends up embedded in
# TOP_LAYER below and corrupts the exec path with "No such file or
# directory".
CHAIN=$( (cd "$REPO_ROOT" && specify preset script-chain "{script_name}") | tr -d '\\r' ) || {{
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py Outdated
@mnriem

mnriem commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback and fix test & lint errors

- Generate the runner next to the dispatcher instead of shipping a new
  scripts/bash file, so shared-infra inventories are unchanged and
  projects initialized earlier still get it.
- Run layers through bash so preset/override scripts need no execute bit.
- Always install the dispatcher while a preset provides the script, so
  enable/disable/set-priority changes that alter chain length stay valid.
- Refuse to write through symlinked destinations; remove a stale generated
  dispatcher when its last provider is removed.
- Resolve the built-in Bash core from scripts/bash and validate that every
  wrap layer contains $CORE_SCRIPT.
- Fall back to python3 -m specify_cli when no specify executable is on PATH.
- Register the hidden script-chain command in the stable-order test.

Disclosure per CONTRIBUTING.md: implemented with Claude Code, autonomous
mode, changes verified by the test suite and manual bash runs.
@Ashfaqbs

Copy link
Copy Markdown
Author

Pushed 73ad604 addressing the Copilot review and the failing CI.

Changed:

  • The runner is now generated next to the dispatcher instead of shipped as a new scripts/bash file. That was what broke the ~40 file-inventory tests, and it also means projects initialized before this feature still get the runner.
  • Layers are run via bash <layer>, so preset copies and overrides need no execute bit.
  • While any preset provides a script, the canonical file is always the dispatcher (even for a one-layer chain), so enable/disable/set-priority changes that move the chain between one and many layers stay valid without a rewrite.
  • Symlinked .specify/scripts, bash or <name>.sh destinations are refused. Removing the last provider deletes the generated dispatcher, or restores the bundled core.
  • The built-in Bash core is now found under scripts/bash/, and every wrap layer is validated to contain $CORE_SCRIPT before the chain is returned.
  • Added the hidden script-chain command to the stable-order registration test.

Not addressed in this push, and I'd like your steer:

  • specify init --force / integration upgrades rewrite .specify/scripts/bash without reconciling already-installed script presets, so an enabled wrapper would be replaced by core until the next preset install/remove. I think the right fix is a hook in the shared-infra refresh path, but that touches code outside the presets module.
  • The dispatcher still needs the CLI at run time. It now falls back to python3 -m specify_cli, but a one-shot uvx project with neither on PATH fails with a clear error. Is a project-local resolver acceptable, or is requiring the CLI fine?

Verified: tests/test_presets.py, tests/specify_cli/presets, a sample of the integration inventory tests and ruff@0.15.0 pass locally. The three real-bash tests skip on my Windows machine, so I ran the two-layer chain and priority-swap scenarios by hand against Git Bash and both give the expected order.

Disclosure: this comment and the commit were produced with Claude Code in autonomous mode, on behalf of @Ashfaqbs.

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.

Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment on lines +299 to +300
elif command -v python3 >/dev/null 2>&1; then
SPECIFY_CMD=(python3 -m specify_cli)
Comment thread src/specify_cli/presets/__init__.py Outdated
- Write the dispatcher, runner and restored core script through the shared
  safe-destination helpers so a symlinked ancestor such as .specify cannot
  redirect writes or the cleanup unlink outside the project.
- Reserve the `common` and `continuation-runner` script names; a dispatcher
  for either would overwrite the runtime helpers every dispatcher relies on.
- Add specify_cli/__main__.py so the `python3 -m specify_cli` fallback works
  when no `specify` executable is on PATH.
- Decide whether a preset provides a script from all active declarations,
  not the truncated chain, so an extension replace layer above a preset no
  longer leaves the preset without a dispatcher.

Disclosure per CONTRIBUTING.md: implemented with Claude Code, autonomous
mode, changes verified by the test suite (one pre-existing PowerShell
failure unrelated to this change).
@Ashfaqbs

Copy link
Copy Markdown
Author

Pushed 4d92033 addressing the latest automated review round:

  • Symlinked ancestors: the dispatcher, runner and restored core script are now written through the shared safe-destination helpers, which validate every ancestor.
  • Helper name collisions: common and continuation-runner are reserved script names and are refused.
  • Python fallback: added specify_cli/__main__.py so python3 -m specify_cli runs the CLI.
  • Chain truncation: whether a preset provides a script is now decided from all active declarations rather than the truncated chain.

The registration-order test item was already covered in 73ad604. Still open and waiting on maintainer direction: reconciling script chains on init --force/upgrades, and resolving the chain without depending on a current specify on PATH.

Disclosure per CONTRIBUTING.md: this comment and the commit were produced with Claude Code in autonomous mode, on behalf of @Ashfaqbs. Tests pass except one PowerShell test that fails on unmodified main.

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.

Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py Outdated
Comment on lines +4121 to +4125
script_names = [
t["name"]
for t in manifest.templates
if t.get("type") == "script"
]
@Ashfaqbs

Copy link
Copy Markdown
Author

Pushed e77742a for the newest Copilot round.

  • Restored core script lost its execute bit: fixed. When the last composing preset is removed, the core script is written back and its execute bits are restored, as the dispatcher branch already does. Regression test added (POSIX only, skipped on Windows).
  • Override-only scripts (.specify/templates/overrides/scripts/<name>.sh added by hand after init): not addressed in this PR. Nothing runs when a user drops a file there, so the fix needs a trigger, most likely generating dispatchers for canonical Bash scripts during shared-infrastructure setup. That changes the init file inventory for every project, so I would rather have your call on it than fold it into this increment. Happy to do it as a follow-up if you want it.

Still open and waiting on maintainer direction: reconciling script chains on init --force/upgrades, and resolving the chain without depending on a current specify on PATH.

Disclosure per CONTRIBUTING.md: this comment and the commit were produced with Claude Code in autonomous mode, on behalf of @Ashfaqbs. Ran tests/test_presets.py and tests/test_script_continuation_bash.py: 624 passed, 4 skipped.

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.

Copilot review overview

🟡 Changes recommended

Runtime parity, ephemeral CLI compatibility, validation, and command-architecture issues remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 3 High severity · 3 Medium severity

Open (6)
Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Low severity Move script-chain command into its dedicated module

src/​specify_cli/​presets/​command_resolve.py:15

The CLI architecture requires each real command to live in its matching command_<name>.py module (design/cli.md:32-58), with tests mirroring that structure. Defining script-chain inside command_resolve.py obscures command ownership; move it to command_script_chain.py and import it at the intended stable registration position.

Comment thread src/specify_cli/presets/__init__.py Outdated
Comment on lines +2497 to +2498
scripts_dir = self.project_root / ".specify" / "scripts" / "bash"
canonical = scripts_dir / f"{script_name}.sh"
Port the script continuation dispatcher onto the private presets modules
introduced by the domain split (github#4747), and move its tests into the
mirrored tests/specify_cli/presets layout.
@Ashfaqbs

Copy link
Copy Markdown
Author

Merged main (e30639f). #4747 split presets/__init__.py into private modules, so the dispatcher code now lives in _manager.py and _resolver.py and its tests moved into tests/specify_cli/presets/ (the shared pack helper is now create_pack in _helpers.py). No behavior change; the presets tests and the new bash continuation tests pass. The one failure in the full run, test_setup_tasks_ps_core_template_resolved (PowerShell JSON output on Windows), is in code this PR doesn't touch.

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.

Comment thread src/specify_cli/presets/_manager.py Outdated
Comment thread src/specify_cli/presets/_manager.py
Comment thread src/specify_cli/presets/_manager.py Outdated
Comment thread src/specify_cli/presets/_resolver.py Outdated
@mnriem

mnriem commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback and test & lint errors

- tests/specify_cli/presets/test_manager.py: drop redundant local
  `import os` / `import subprocess` that shadowed the already-imported
  module-level names, fixing the ruff F401/F811 failures blocking CI.
- _manager.py / _resolver.py: fix mojibake (`—`) back to em dashes in
  comments and docstrings.
- _manager.py: add `PresetManager.reconcile_all_script_chains()`, and
  call it from command_init.py right after `install_shared_infra`.
  `specify init --force` (and forced integration upgrades) rewrite
  `.specify/scripts/bash/<name>.sh` from the bundled core, clobbering
  any generated continuation dispatcher for an already-enabled script
  preset. Unlike commands/skills, script presets were never
  re-reconciled after that refresh, so a wrapped script would go inert
  until its preset was reinstalled. Covered by a new regression test.

Addresses the ruff/pytest CI failures and the Copilot review round on
this PR. Several other findings from that review (execute-bit
preservation after restore, symlink-safe writes/deletes, reserved
script names, wrap-strategy validation, the `python3 -m specify_cli`
fallback, and chain truncation vs. extension layers) were already
fixed in earlier commits on this branch. PowerShell/Python script
parity and the general override-only reconciliation-trigger gap
(pre-existing for commands too, not a regression here) remain
out of scope for this first increment, per the PR description.

Assisted-by: Claude Code (model: claude-sonnet-5, autonomous)
@Ashfaqbs

Ashfaqbs commented Sep 28, 2026 •

Copy link
Copy Markdown
Author

5873800680.txt

…untime

Replaces the per-invocation runtime dispatcher (hidden `preset script-chain`
CLI command, generated continuation-runner, SPECKIT_SCRIPT_CONTINUATION env
protocol, `__main__.py` fallback, uvx caveat doc) with a fixed chain of
generated launcher files written to disk at preset install/remove/enable/
disable/set-priority time, mirroring how preset commands are already
materialized. Each layer's $CORE_SCRIPT now points at a fixed next-hop
path baked in at materialization time instead of being resolved live via
a `specify` subprocess call on every script invocation.

Per mnriem's review on github#4709: the dynamic-resolution model needed more
runtime machinery (hidden CLI surface, continuation protocol, PATH/import
fallback, a documented uvx limitation) than preset script composition
warrants, and commands already show the materialized approach works.

Assisted-by: Claude Code (model: Claude Sonnet 5, autonomous)
@Ashfaqbs Ashfaqbs changed the title feat(presets): runtime continuation dispatcher for script composition (#4551) feat(presets): materialize script composition chains at lifecycle time (#4551) Oct 9, 2026
@Ashfaqbs

Ashfaqbs commented Oct 9, 2026

Copy link
Copy Markdown
Author

@mnriem pushed b5ccc7f reworking this toward the materialization model, per the plan in my earlier comment.

Summary of the change:

  • PresetManager._reconcile_script_chain() now writes a fixed-path generated launcher per composing layer under .specify/scripts/bash/ instead of a runtime dispatcher: the topmost lands at the canonical <name>.sh, each layer's $CORE_SCRIPT points at the next-lower layer's fixed path, ending in a materialized copy of the base layer. A single "replace" layer with no composition stays a verbatim write, no intermediate files.
  • Install, remove, enable, disable, and set-priority all re-materialize now (reconcile_scripts_for_preset(), mirroring reconcile_constitution()) — previously enable/disable/set-priority did nothing for scripts at all, which this closes.
  • Removed: the hidden specify preset script-chain CLI command, continuation-runner.sh, the SPECKIT_SCRIPT_CONTINUATION env protocol, the __main__.py fallback, and the uvx one-time-install caveat doc.
  • PresetResolver.resolve_script_chain() is untouched — same ordering logic, just called at reconcile time instead of invocation time now.

Full suite: 8120 passed, 468 skipped (environment-gated), 1 pre-existing failure unrelated to this PR (confirmed identical on main before my changes via git stash). Updated the PR description with the full before/after.

Implemented with Claude Code (model: Claude Sonnet 5, autonomous); comment fully AI-drafted, diff and test results reviewed by me before pushing.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Comment thread src/specify_cli/presets/_manager.py Outdated
Comment on lines +1886 to +1891
def test_enable_disable_and_set_priority_rematerialize_scripts(
self, project_dir, temp_dir, valid_pack_data
):
"""Unlike commands (which stay stale until removal), scripts must
take effect immediately on enable/disable/set-priority (#4551) --
mnriem's pivot explicitly closes this gap."""
Comment thread src/specify_cli/presets/_resolver.py Outdated
@mnriem

mnriem commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

Addresses the latest Copilot review round on github#4709:

- _script_launcher_stub() embedded a preset/override script's absolute
  path inside bash double quotes, which still expands $, backticks, and
  command substitution; a path segment containing any of those (or the
  project simply being moved/copied) broke or hijacked the generated
  launcher. Resolve the path relative to $SCRIPT_DIR instead, and
  single-quote the relative fragment so it stays inert regardless of
  content.
- Removed the now-stale resolve_script_chain() docstring paragraph that
  still described the runtime continuation runner dropped in b5ccc7f.
- Added CLI-level coverage (test_command_enable.py,
  test_command_set_priority.py) asserting the materialized script chain
  actually changes through `specify preset enable/disable/set-priority`,
  not just through a direct reconcile_scripts_for_preset() call -- the
  existing test_manager.py coverage calls that method itself and so
  can't catch missing wiring in the command handlers.

Assisted-by: Claude Code (model: Claude Sonnet 5, autonomous)
@Ashfaqbs

Copy link
Copy Markdown
Author

Pushed 5260c2c addressing the latest Copilot round on the materialization rework.

  • _script_launcher_stub() embedded a preset/override script's path raw inside bash double quotes, which still expands $, backticks, and command substitution -- a path segment containing any of those (or the project just being moved/copied, since the embedded path was absolute) broke or hijacked the generated launcher. The path is now resolved relative to $SCRIPT_DIR and single-quoted, so it stays inert and portable regardless of content. Verified by hand against a real path containing a literal $ under Git Bash.
  • Removed the resolve_script_chain() docstring paragraph still describing the runtime continuation runner dropped in b5ccc7f.
  • Added CLI-level coverage in test_command_enable.py/test_command_set_priority.py asserting the materialized chain changes through specify preset enable/disable/set-priority itself, not just through a direct reconcile_scripts_for_preset() call -- the existing test_manager.py coverage calls that method directly and so couldn't catch missing wiring in the command handlers.

Full suite green locally (presets + continuation tests); ruff@0.15.0 check src tests clean.

Assisted-by: Claude Code (model: Claude Sonnet 5, autonomous); comment fully AI-drafted, diff and test results reviewed by me before pushing.

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.

🔵 Needs a closer look

Forced upgrades can still overwrite restored launchers, and extension lifecycle changes leave materialized chains stale.

9 open findings
3 resolved since last review
Previously missed (3)

In code that hasn't changed since last review

Medium severity Add command-level regression test for forced initialization

src/​specify_cli/​command_init.py:834

The forced-init wiring is not exercised by a command-level regression test. The manager test only calls reconcile_all_script_chains() directly, so it would still pass if this call were removed or placed before the shared-infra overwrite. Add an init --here --force test with a preset overriding a bundled script (for example setup-plan) and assert the launcher is restored afterward.

Medium severity Reconcile script chains during extension lifecycle changes

src/​specify_cli/​presets/​_manager.py:665

The resolved chain can include an extension-provided replace layer, but only preset lifecycle paths trigger this materializer. Installing, removing, enabling, disabling, or reprioritizing an extension does not reconcile script chains, so a wrapping preset keeps executing a stale copied extension script (even after that extension is disabled or removed). Extension lifecycle handlers need to reconcile affected script names, and full reconciliation should enumerate extension-only script declarations too.

Medium severity Use bundled script in switch regression test

tests/​specify_cli/​integrations/​test_command_switch.py:140

This regression test uses switch-me, which does not exist in bundled shared infrastructure, so the forced refresh never clobbers it and the test still passes if reconciliation is removed. Use a real bundled script name such as setup-plan so the test proves the force-refresh recovery path.

This issue also appears on line 186 of the same file.

🧠 Review effort: Balanced

@mnriem

mnriem commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

…fresh

`integration upgrade --force` on the already-default integration refreshes
shared infrastructure twice: once explicitly, then again via the Phase 2
re-setup that runs when installed_key == key. The dispatcher chain restored
after the first refresh was immediately re-clobbered by the second, leaving
a script preset's generated launcher silently reverted to the bundled core
script. Reconciliation now runs after whichever refresh is actually last.

Also reject installing a script-providing preset into a project configured
for --script ps/py: the generated dispatcher chain is Bash-only, so such an
install previously succeeded but never took effect.

Assisted-by: Claude Code (model: Claude Sonnet 5, autonomous)
@Ashfaqbs

Copy link
Copy Markdown
Author

Posted by Claude Code (model: Claude Sonnet 5, autonomous); comment and the paired commit fully AI-drafted.

Commit ea07da1 addresses the review:

  • Fixed the dispatcher clobber in integration upgrade --force for the already-default integration: the command performs shared-infra refresh twice (an explicit one, then a Phase 2 re-setup), and the second was overwriting the generated script-preset launcher chain the first refresh's reconciliation had just restored. Reconciliation now runs after whichever refresh is actually last.
  • Script-providing presets are now rejected outright on projects configured with --script ps/--script py, instead of installing successfully with no effect — the generated dispatcher chain is Bash-only, so that was previously silent dead weight rather than working PowerShell/Python support. Full multi-language dispatcher generation is a larger follow-up, not attempted here.
  • Checked the python3 -m specify_cli fallback and the bare-specify-on-PATH concerns raised earlier: both are already moot under the materialize-at-lifecycle-time design from b5ccc7f — the generated launchers are pure Bash with no runtime CLI dependency at all.
  • Added regression coverage: a command-level integration upgrade --force test, an init --here --force test, and a preset-install rejection test (plus a companion test confirming non-script presets still install fine on ps/py projects).

Full suite is green except one pre-existing, unrelated failure (test_setup_tasks_ps_core_template_resolved) caused by this machine's OEM codepage mis-encoding a non-ASCII character in test output; confirmed it reproduces identically on main and isn't touched by this branch's diff.

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.

🟡 Changes recommended

Cleanup can delete user edits, invalid wrappers can install successfully, and runtime compatibility is not enforced across all transitions.

9 open findings
1 resolved since last review
Previously missed (2)

In code that hasn't changed since last review

Medium severity Enforce script compatibility across all runtime transitions

src/​specify_cli/​presets/​_manager.py:307

This compatibility guard only runs during preset installation and also treats missing init options as Bash. Consequently, a legacy Windows project (whose default runtime is PowerShell) can install an inert script preset, and an existing Bash script preset can later be made inert via init --force --script ps|py or integration switch --script ... because those runtime-change paths do not call this check. Resolve the effective runtime using the shared platform-default logic and enforce compatibility before every runtime transition.

Medium severity Reject invalid script presets instead of installing inert launchers

src/​specify_cli/​presets/​_manager.py:603

A wrap script missing $CORE_SCRIPT raises PresetValidationError in resolve_script_chain(), but this blanket catch turns that into a warning and returns a successfully installed preset with no materialized launcher. Validate script chains before committing the installation, or roll back and re-raise validation failures, so invalid script presets cannot remain enabled but inert.

🧠 Review effort: Balanced

Comment thread src/specify_cli/presets/_manager.py
@mnriem

mnriem commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

1 similar comment
@mnriem

mnriem commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback

…er is removed

The provenance sidecar only proved a canonical script path was
generated at some earlier point, not that it still matches the
generated content. Removing the last preset provider for a
replace-strategy script (written verbatim with no in-content marker)
unconditionally deleted the file, including any hand edits a user
made after installation.

Record the generated content's SHA-256 in the sidecar, mirroring the
existing constitution provenance pattern, and only unlink the
canonical file when the current hash still matches. On a mismatch,
preserve the user's edits and just drop the now-stale ownership
metadata.

Assisted-by: Claude Code (model: Claude Sonnet 5, autonomous)
@Ashfaqbs

Ashfaqbs commented Oct 13, 2026 •

Copy link
Copy Markdown
Author

Implemented with Claude Code (model: Claude Sonnet 5, autonomous mode); comment fully AI-drafted.

Addressed the latest Copilot finding (comment): _reconcile_script_chain's final cleanup branch unlinked the canonical script whenever the provenance sidecar existed, even if the user had hand-edited that file after it was generated (the file carries no in-content marker for a verbatim-written replace-strategy script).

Fix: the sidecar now stores the generated content's SHA-256 (mirroring the existing constitution-provenance pattern) via a new _write_script_provenance/_script_provenance_matches pair in src/specify_cli/presets/_manager.py. The unlink only runs when the canonical file's current hash still matches; on a mismatch the file is preserved and only the stale provenance metadata is removed. Added test_remove_only_provider_preserves_user_edited_dispatcher in tests/specify_cli/presets/test_manager.py to cover it, alongside the existing unedited-removal test.

Commit: 1ee843f

Full preset-manager suite (98 tests) and the integrations/presets suites (2904 tests) pass.

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.

🔵 Needs a closer look

Extension lifecycle changes, runtime transitions, and corrupt-manifest removal can leave script chains stale or inert.

8 open findings
1 resolved since last review
Previously missed (1)

In code that hasn't changed since last review

Medium severity Reject runtime changes incompatible with installed script presets

src/​specify_cli/​presets/​_manager.py:308

This guard only runs when a preset is installed, but the project's runtime can later change through commands such as integration upgrade --script ps|py, integration switch ... --script ps|py, or forced re-init. Those transitions currently succeed with an enabled Bash script preset still installed, after which agents invoke the non-Bash variant and the preset silently becomes inert. Reject unsupported runtime transitions when script presets are enabled (or implement cross-runtime materialization), with a regression test covering an already-installed preset.

This issue also appears in the following locations of the same file:

  • line 703
  • line 1134

🧠 Review effort: Balanced

@mnriem

mnriem commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

@Ashfaqbs While I am very much looking forward to this change to resolve the issue you have been working I think we should regroup a bit as it appears we are going into a long review cycle. Maybe we should file this as a separate issue and determine how we can decompose this into several smaller backwards compatible steps? Thoughts?

… installed

Installing a script-providing preset into a non-Bash project was
already rejected, but a project could still change runtime *after*
installation via `integration upgrade --script ps|py`, `integration
switch --script ps|py`, or `init --here --force --script ps|py`. Those
transitions succeeded silently, leaving the already-installed preset's
Bash-only generated dispatcher chain inert, since the project now
executes the restored PowerShell/Python variant instead.

Add PresetManager.reject_unsupported_script_runtime_change(), called
from all three command paths right after the target script type is
resolved and before any other mutation, and a
_script_providing_preset_ids() helper (also covering a project-local
script override with no owning preset) to name what's blocking the
change.

Assisted-by: Claude Code (model: Claude Sonnet 5, autonomous)
@Ashfaqbs

Copy link
Copy Markdown
Author

Implemented with Claude Code (model: Claude Sonnet 5, autonomous mode); comment fully AI-drafted.

Addressed the "Previously missed" finding from the latest review round: the Bash-only script-preset guard (_reject_script_preset_for_unsupported_runtime) only ran at preset-install time. A project could still change runtime afterward via integration upgrade --script ps|py, integration switch --script ps|py, or init --here --force --script ps|py, silently leaving an already-installed script preset's Bash-only dispatcher chain inert.

Added PresetManager.reject_unsupported_script_runtime_change() in src/specify_cli/presets/_manager.py, called from all three command entry points (command_init.py, integrations/command_upgrade.py, integrations/command_switch.py) right after the target script type is resolved, before any other mutation. A _script_providing_preset_ids() helper names the blocking preset(s) in the error, also covering a project-local script override with no owning preset. Added one regression test per command path.

The remaining 7 open findings restated in this review round are unchanged from prior rounds (Bash-only dispatcher scope, stale PATH/specify-executable concerns obsoleted by the materialize-at-lifecycle-time redesign, and the documented override-only reconciliation tradeoff) — already triaged in the preceding summary comment.

Commit: dd159a0

Full preset-manager/integrations suites (3567 tests) pass.

@mnriem mnriem added author-awaiting Waiting on author response author-needs-info Missing detail needed to assess — supply requested info labels Oct 13, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author-awaiting Waiting on author response author-needs-info Missing detail needed to assess — supply requested info triage-can-wait Verdict: valid and in-scope but deprioritized; held behind the evidence gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants