mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* fix(shared-infra): remove stale managed scripts the core no longer ships (#3076) install_shared_infra never removed shared scripts a prior (pre-refactor) install recorded but the current core no longer ships — e.g. the legacy scripts/<variant>/update-agent-context.sh, superseded by the bundled agent-context extension. On a legacy project the orphan lingers and crashes when it sources a refreshed common.sh (HAS_GIT unbound under set -u). Apply the stale-removal that integration_upgrade already performs to install_shared_infra: manifest-tracked scripts the current bundle no longer produces are removed, but only managed copies (hash matches the manifest); user-customized files, symlinks, and recovered entries are preserved. Guarded so a missing/empty source can't trigger mass deletion, and the safe-destination check prevents unlinking through a symlinked ancestor. Add IntegrationManifest.remove(); drop the stale update-agent-context.sh reference in CONTRIBUTING.md. AI assistance: implemented with Claude Code (Anthropic); reviewed and validated locally (ruff clean, full suite 4176 passed, manual CLI repro). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(shared-infra): harden stale-cleanup per review (empty source + orphan manifest) - Set scripts_scanned only after a real source file is seen, so an empty variant source can't trigger mass deletion of tracked scripts. - Prune a stale manifest entry even when its file is already gone from disk, keeping the manifest consistent (previously left tracked forever). - Add a test for each edge case. Addresses the Copilot review comments on #3098. AI assistance: Claude Code (Anthropic), reviewed/validated locally (ruff clean, full suite 4178 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(shared-infra): guard unsafe manifest keys in stale-cleanup (review) - Skip absolute / '..' manifest keys before any filesystem access in stale-cleanup, so a corrupted/hand-edited manifest can't make it touch paths outside the project root (mirrors IntegrationManifest.check_modified / uninstall). - Clarify the scripts_scanned comment: the safety hinge is that flag, not seen_rels (which also holds template paths). - Add a containment test: a traversal manifest key is skipped, its target untouched. Addresses the second round of Copilot review on #3098. AI assistance: Claude Code (Anthropic); validated locally (ruff clean, full suite 4179 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(manifest): make remove() reject absolute/.. keys like its siblings (review) IntegrationManifest.remove() now applies the same lexical validation and normalization as record_existing() / is_recovered(): absolute paths and '..' segments are rejected (return False) instead of being used verbatim as a key. Keeps the manifest API consistent. Adds tests (valid drop + no-op, absolute rejected, traversal rejected). Addresses the third round of Copilot review on #3098. AI assistance: Claude Code (Anthropic); validated locally (ruff clean, full suite 4182 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(shared-infra): validate stale-cleanup keys for containment, not just lexically (review) The stale-script cleanup guarded manifest keys with a lexical check only (is_absolute() / ".." segments). On Windows a drive-relative key such as "C:tmp\\file" is not is_absolute(), yet joining it onto the project path discards the root — so cleanup could stat/unlink outside the project before _ensure_safe_shared_destination raised, and a corrupted manifest key turned into an install-time hard failure (ValueError) instead of being skipped. Reuse the canonical containment helper (_validate_rel_path, the same one IntegrationManifest.is_recovered / remove use): after the fast lexical reject, resolve the join and confirm it stays within the project root; a key that still escapes is skipped, never unlinked, never fatal. Adds a regression test that forces _validate_rel_path to reject a managed key (portably simulating the Windows drive-relative escape) and asserts the install skips it without failing and still installs the real scripts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
3c11f4d90b
commit
e5a03bffc8
@@ -263,6 +263,206 @@ class TestInitIntegrationFlag:
|
||||
assert (scripts_dir / "setup-plan.sh").exists()
|
||||
assert (templates_dir / "plan-template.md").exists()
|
||||
|
||||
def test_shared_infra_removes_stale_managed_script(self, tmp_path):
|
||||
"""A managed script the core no longer ships (e.g. the legacy
|
||||
update-agent-context.sh, superseded by the agent-context extension) is
|
||||
removed, and the manifest stops tracking it (#3076)."""
|
||||
from specify_cli import _install_shared_infra
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
project = tmp_path / "stale-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
|
||||
# Legacy orphan the current bundle no longer ships, recorded in the
|
||||
# manifest as a managed file (hash matches on disk) — a pre-refactor install.
|
||||
stale_rel = ".specify/scripts/bash/update-agent-context.sh"
|
||||
(scripts_dir / "update-agent-context.sh").write_text("# legacy orphan\n", encoding="utf-8")
|
||||
manifest = IntegrationManifest("speckit", project, version="test")
|
||||
manifest.record_existing(stale_rel)
|
||||
manifest.save()
|
||||
|
||||
_install_shared_infra(project, "sh", force=False)
|
||||
|
||||
# The orphan is gone and the manifest no longer tracks it.
|
||||
assert not (scripts_dir / "update-agent-context.sh").exists()
|
||||
refreshed = IntegrationManifest.load("speckit", project)
|
||||
assert stale_rel not in refreshed.files
|
||||
# Scripts the core DOES ship are installed and tracked.
|
||||
assert (scripts_dir / "common.sh").exists()
|
||||
assert ".specify/scripts/bash/common.sh" in refreshed.files
|
||||
|
||||
def test_shared_infra_preserves_modified_stale_script(self, tmp_path):
|
||||
"""A user-modified stale script is preserved (hash diverges from the
|
||||
managed baseline), never silently deleted (#3076)."""
|
||||
from specify_cli import _install_shared_infra
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
project = tmp_path / "stale-modified"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
|
||||
stale = scripts_dir / "update-agent-context.sh"
|
||||
stale.write_text("# original managed\n", encoding="utf-8")
|
||||
manifest = IntegrationManifest("speckit", project, version="test")
|
||||
manifest.record_existing(".specify/scripts/bash/update-agent-context.sh")
|
||||
manifest.save()
|
||||
|
||||
# User customizes it after install → on-disk hash now diverges.
|
||||
stale.write_text("# user customization\n", encoding="utf-8")
|
||||
|
||||
_install_shared_infra(project, "sh", force=False)
|
||||
|
||||
# Preserved: it is no longer a managed (hash-matching) copy.
|
||||
assert stale.exists()
|
||||
assert stale.read_text(encoding="utf-8") == "# user customization\n"
|
||||
|
||||
def test_shared_infra_prunes_orphan_manifest_entry_when_file_absent(self, tmp_path):
|
||||
"""A stale manifest entry whose file is already gone from disk is pruned
|
||||
so the manifest stays consistent, not left tracked forever (#3076 review)."""
|
||||
from specify_cli import _install_shared_infra
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
project = tmp_path / "orphan-entry"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
|
||||
stale_rel = ".specify/scripts/bash/update-agent-context.sh"
|
||||
stale = scripts_dir / "update-agent-context.sh"
|
||||
stale.write_text("# legacy orphan\n", encoding="utf-8")
|
||||
manifest = IntegrationManifest("speckit", project, version="test")
|
||||
manifest.record_existing(stale_rel)
|
||||
manifest.save()
|
||||
# File removed out of band, but the manifest still tracks it.
|
||||
stale.unlink()
|
||||
|
||||
_install_shared_infra(project, "sh", force=False)
|
||||
|
||||
refreshed = IntegrationManifest.load("speckit", project)
|
||||
assert stale_rel not in refreshed.files
|
||||
|
||||
def test_shared_infra_empty_script_source_keeps_tracked_scripts(self, tmp_path, monkeypatch):
|
||||
"""If the bundle's script source dir exists but is empty, stale-cleanup
|
||||
must NOT run (no source files seen → can't tell what's obsolete): a
|
||||
previously-tracked script is preserved, never mass-deleted (#3076 review)."""
|
||||
from specify_cli import _install_shared_infra, shared_infra
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
# Point the script source at an empty ``bash/`` directory.
|
||||
empty_src = tmp_path / "empty-bundle" / "scripts"
|
||||
(empty_src / "bash").mkdir(parents=True)
|
||||
monkeypatch.setattr(shared_infra, "shared_scripts_source", lambda **kw: empty_src)
|
||||
|
||||
project = tmp_path / "empty-source"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
tracked_rel = ".specify/scripts/bash/common.sh"
|
||||
(scripts_dir / "common.sh").write_text("# tracked\n", encoding="utf-8")
|
||||
manifest = IntegrationManifest("speckit", project, version="test")
|
||||
manifest.record_existing(tracked_rel)
|
||||
manifest.save()
|
||||
|
||||
_install_shared_infra(project, "sh", force=False)
|
||||
|
||||
# Empty source → scripts_scanned stays False → nothing deleted.
|
||||
assert (scripts_dir / "common.sh").exists()
|
||||
refreshed = IntegrationManifest.load("speckit", project)
|
||||
assert tracked_rel in refreshed.files
|
||||
|
||||
def test_shared_infra_stale_cleanup_ignores_unsafe_manifest_keys(self, tmp_path):
|
||||
"""A corrupted/hand-edited manifest key with a ``..`` segment is skipped
|
||||
before any filesystem access — its traversal target is never deleted
|
||||
(#3076 review, containment guard)."""
|
||||
import hashlib
|
||||
import json
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "unsafe-key"
|
||||
project.mkdir()
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
manifest_dir = project / ".specify" / "integrations"
|
||||
manifest_dir.mkdir(parents=True)
|
||||
|
||||
# A file the traversal key would resolve to (outside scripts/bash/).
|
||||
victim = project / ".specify" / "scripts" / "keep-me.sh"
|
||||
victim_bytes = b"# do not touch\n"
|
||||
victim.write_bytes(victim_bytes)
|
||||
|
||||
# Hand-crafted manifest: a key under the script prefix but with a ``..``
|
||||
# segment, with the *matching* hash so that — absent the containment guard
|
||||
# — stale-cleanup would consider it managed and unlink the target.
|
||||
traversal_key = ".specify/scripts/bash/../keep-me.sh"
|
||||
(manifest_dir / "speckit.manifest.json").write_text(
|
||||
json.dumps({
|
||||
"integration": "speckit",
|
||||
"version": "test",
|
||||
"files": {traversal_key: hashlib.sha256(victim_bytes).hexdigest()},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
_install_shared_infra(project, "sh", force=False)
|
||||
|
||||
# The unsafe key was skipped; its target file is untouched.
|
||||
assert victim.exists()
|
||||
assert victim.read_bytes() == victim_bytes
|
||||
|
||||
def test_shared_infra_stale_cleanup_skips_escaping_key_without_failing(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""A key that passes the lexical guard but escapes containment — e.g. a
|
||||
Windows drive-relative ``C:tmp`` that is not ``is_absolute()`` yet discards
|
||||
the project root when joined — is skipped via ``_validate_rel_path``, never
|
||||
unlinked, and never turned into an install-time hard failure (#3076 review
|
||||
round 4). Simulated portably by forcing ``_validate_rel_path`` to reject the
|
||||
managed key, since real drive-relative paths only escape on Windows."""
|
||||
from specify_cli import _install_shared_infra
|
||||
from specify_cli.integrations import manifest as manifest_mod
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
project = tmp_path / "escaping-key"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
|
||||
# A managed stale orphan that would normally be removed.
|
||||
stale_rel = ".specify/scripts/bash/update-agent-context.sh"
|
||||
stale = scripts_dir / "update-agent-context.sh"
|
||||
stale.write_text("# legacy orphan\n", encoding="utf-8")
|
||||
manifest = IntegrationManifest("speckit", project, version="test")
|
||||
manifest.record_existing(stale_rel)
|
||||
manifest.save()
|
||||
|
||||
# Force the containment check to reject this key, as it would for a
|
||||
# drive-relative escape on Windows. The cleanup must skip it gracefully.
|
||||
real_validate = manifest_mod._validate_rel_path
|
||||
|
||||
def fake_validate(rel, root):
|
||||
if str(rel).endswith("update-agent-context.sh"):
|
||||
raise ValueError("simulated drive-relative escape")
|
||||
return real_validate(rel, root)
|
||||
|
||||
monkeypatch.setattr(manifest_mod, "_validate_rel_path", fake_validate)
|
||||
|
||||
# Must not raise (no install-time hard failure from a corrupted key).
|
||||
_install_shared_infra(project, "sh", force=False)
|
||||
|
||||
# The escaping key was skipped, so its file is left untouched...
|
||||
assert stale.exists()
|
||||
assert stale.read_text(encoding="utf-8") == "# legacy orphan\n"
|
||||
# ...yet the install otherwise completed: real scripts are installed.
|
||||
assert (scripts_dir / "common.sh").exists()
|
||||
|
||||
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
|
||||
"""Console warning is displayed when files are skipped."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
Reference in New Issue
Block a user