Skip to content

fix(shared-infra): record skipped files in speckit.manifest.json#2483

Open
eldar702 wants to merge 2 commits intogithub:mainfrom
eldar702:fix/2107-speckit-manifest-record-skipped
Open

fix(shared-infra): record skipped files in speckit.manifest.json#2483
eldar702 wants to merge 2 commits intogithub:mainfrom
eldar702:fix/2107-speckit-manifest-record-skipped

Conversation

@eldar702
Copy link
Copy Markdown

@eldar702 eldar702 commented May 7, 2026

Summary

In install_shared_infra (src/specify_cli/shared_infra.py), the skip branches in both the scripts loop and the templates loop now record each skipped file in speckit.manifest.json, so a fresh-manifest run against an already-populated .specify/ tree no longer writes an empty files field.

Fixes #2107

Problem

When install_shared_infra ran with force=False against a project that already had files under .specify/scripts/ and .specify/templates/ — but a fresh, empty manifest — every iteration hit the if dst.exists() and not force: skipped_files.append(...); continue branch. planned_copies and planned_templates stayed empty, the post-loop record loop had nothing to record, and manifest.save() serialised files: {}. The integration then believed nothing had been installed.

This bites users who delete or lose speckit.manifest.json, who extract .specify/ out-of-band, or who hit a code path where the manifest can't be loaded but the directory tree is intact.

Solution

Call manifest.record_existing(rel_skip) from inside both skip branches, but only when the path is not already tracked:

if dst_path.exists() and not force:
    rel_skip = dst_path.relative_to(project_path).as_posix()
    skipped_files.append(rel_skip)
    if rel_skip not in manifest.files:
        manifest.record_existing(rel_skip)
    continue

The guard matters: record_existing always re-hashes the on-disk content, so without it a customized template would have its manifest hash overwritten with the customized hash, defeating the user-modification detection that integration use relies on (test_use_preserves_modified_templates_unless_forced is the canonical regression test for that flow).

Symmetric change in both the scripts loop and the templates loop.

Test plan

  • New regression test TestSpeckitManifestRecordsSkippedFiles::test_install_shared_infra_records_skipped_files — populates .specify/ via a normal first run, deletes the manifest, runs install_shared_infra again with force=False (every file goes down the skip branch), asserts the saved manifest is non-empty and is a superset of the first run's files. Fails on main, passes after the fix.
  • Existing test_use_preserves_modified_templates_unless_forced continues to pass thanks to the if rel_skip not in manifest.files: guard — customized templates keep their original hash, so integration use still skips overwriting them.
  • pytest full suite: 2787 passed, 34 skipped — zero regressions.

Notes

This change was AI-assisted. The fix was selected after a 3-agent solution-design debate where two designers independently identified the same root cause (skip branch in install_shared_infra failing to record), giving high convergent confidence. A third designer initially proposed a fix in claude/__init__.py, but that targets the integration manifest (claude.manifest.json), not the shared-infrastructure manifest (speckit.manifest.json) the issue describes — convergent analysis correctly localized the right file.

A first attempt at the fix omitted the if rel_skip not in manifest.files: guard and broke test_use_preserves_modified_templates_unless_forced (customized templates were silently re-hashed). Adding the guard fixed the regression while still recovering from a lost-manifest scenario.

`install_shared_infra` skipped files that already existed on disk
when `force=False`, but the skip branches in both the scripts loop
and the templates loop only appended to `skipped_files` without
calling `manifest.record_existing`. So when the function ran with a
fresh manifest against an already-populated `.specify/` tree (e.g.
after the manifest was deleted, corrupted, or extracted out of band),
every file went down the skip path, `planned_copies` /
`planned_templates` stayed empty, and `manifest.save()` wrote an
empty `files` field — leaving the integration believing nothing was
installed.

Record every skipped file in the manifest, but only when it is not
already tracked. This preserves the original hash for files that
were previously recorded so `check_modified()` (used by
`integration use` to decide whether a user has customized a
template) keeps working correctly.

Add `TestSpeckitManifestRecordsSkippedFiles` in
`tests/integrations/test_integration_claude.py` covering both the
fresh-skip path and the recover-after-lost-manifest path.

Fixes github#2107
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a shared-infrastructure tracking bug where install_shared_infra(..., force=False) could skip all existing .specify/scripts/ and .specify/templates/ files without recording them, resulting in speckit.manifest.json being saved with an empty files mapping (issue #2107).

Changes:

  • Record skipped (already-existing) shared scripts/templates into speckit.manifest.json during install_shared_infra when they are not already tracked.
  • Add a regression test ensuring a “lost manifest” reinstall reconstructs a non-empty manifest that includes all previously tracked files.
Show a summary per file
File Description
src/specify_cli/shared_infra.py Records skipped existing shared infra files into the shared manifest during install.
tests/integrations/test_integration_claude.py Adds a regression test covering the “lost manifest + skip branch” scenario.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comments suppressed due to low confidence (1)

src/specify_cli/shared_infra.py:303

  • Same issue as the scripts loop: calling manifest.record_existing(rel_skip) in the skip branch will raise if dst exists but is not a regular file (e.g., a directory). Add an dst.is_file() guard or raise an explicit error so a non-file collision doesn’t fail with an opaque hashing/open error.
                # ``.specify/`` tree does not silently drop it (#2107).
                # Skip if already tracked — preserving the original hash
                # keeps user-modification detection working downstream.
                if rel_skip not in manifest.files:
                    manifest.record_existing(rel_skip)
  • Files reviewed: 2/2 changed files
  • Comments generated: 2

Comment thread src/specify_cli/shared_infra.py Outdated
Comment on lines +273 to +277
# ``.specify/`` tree does not silently drop it (#2107).
# Skip if already tracked — preserving the original hash
# keeps user-modification detection working downstream.
if rel_skip not in manifest.files:
manifest.record_existing(rel_skip)
f"speckit.manifest.json not written at {manifest_path}"
)
data = json.loads(manifest_path.read_text(encoding="utf-8"))
return data.get("files") or data.get("_files") or {}
@eldar702 eldar702 marked this pull request as ready for review May 7, 2026 19:23
@eldar702 eldar702 requested a review from mnriem as a code owner May 7, 2026 19:23
@mnriem

This comment was marked as outdated.

Copy link
Copy Markdown
Collaborator

@mnriem mnriem left a comment

Choose a reason for hiding this comment

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

Please address Copilot feedback

Address Copilot review feedback on PR github#2483. The previous fix called
``manifest.record_existing(rel_skip)`` from the skip branch of both
loops in ``install_shared_infra``, which would crash with
``IsADirectoryError`` (or another ``OSError``) if a directory or other
non-regular-file happened to exist at the expected destination path —
since ``record_existing`` opens the file to compute its SHA-256.

Three coordinated fixes:

1. ``IntegrationManifest.record_existing`` now validates its
   precondition: it raises ``ValueError`` if the path is a symlink or
   is not a regular file. The docstring already promised "an
   already-existing file"; this enforces it. The symlink check runs on
   the un-resolved path because ``_validate_rel_path`` calls
   ``resolve()``, which would silently follow the symlink. Mirrors the
   existing ``_ensure_safe_manifest_destination`` precedent in the
   same module.

2. In ``install_shared_infra``'s scripts and templates skip branches,
   guard the ``record_existing`` call with ``dst.is_file()`` and wrap
   it in ``try/except (OSError, ValueError)``. A directory collision,
   permission error, or TOCTOU race no longer aborts the whole
   install — the user gets a per-path warning, the path still
   surfaces in ``skipped_files``, and the rest of the install
   continues.

3. ``_read_manifest_files`` in the regression test no longer falls
   back to ``data.get("_files")`` (Copilot's low-confidence finding):
   the silent fallback could mask a schema regression where the
   public ``files`` key is renamed. It now asserts ``"files" in data``
   and that the value is a dict.

Add two regression tests in ``TestSpeckitManifestRecordsSkippedFiles``
covering the directory-at-destination edge case for both the scripts
loop and the templates loop. Both verify (a) install does not crash,
(b) the non-file path is not recorded in the manifest, and (c) the
path still surfaces in the user-visible warning.

The "shared infrastructure file(s)" warning text is changed to
"path(s)" so it remains accurate when non-file entries appear in the
list.

Refs github#2107
@eldar702
Copy link
Copy Markdown
Author

Thanks for the review! Pushed 6790654 to address all three Copilot findings.

What changed

1. Hardened IntegrationManifest.record_existing (the choke point)

The function's docstring already promised "an already-existing file", but didn't enforce it. It now raises ValueError if the path is a symlink or is not a regular file — mirroring the existing _ensure_safe_manifest_destination precedent in the same module. The is_symlink() check runs on the un-resolved path because _validate_rel_path calls resolve(), which would otherwise silently follow the symlink and record the target.

2. Guarded both call sites in install_shared_infra (the bug Copilot flagged at lines 277 + 303)

if dst_path.is_file() and rel_skip not in manifest.files:
    try:
        manifest.record_existing(rel_skip)
    except (OSError, ValueError) as exc:
        console.print(
            f"[yellow]⚠[/yellow]  could not record {rel_skip} in manifest: {exc}"
        )

Belt-and-suspenders: the is_file() guard short-circuits the common case (directory collision), and the try/except absorbs the rare TOCTOU race or permission error so a single weird path can't abort the whole install. The path still surfaces via the existing skipped_files warning at the bottom of the function.

3. Tightened the test helper (Copilot's low-confidence finding)

_read_manifest_files no longer falls back to data.get("_files"). It now asserts "files" in data and that the value is a dict, with descriptive failure messages. A future schema regression that renames the public key now fails red instead of being silently masked.

4. New regression tests

Two new methods in TestSpeckitManifestRecordsSkippedFiles plant a directory at the expected destination of a script and a template, then assert (a) install completes, (b) the path is NOT recorded in the manifest, (c) the path still appears in the user-visible warning.

5. Cosmetic

The user-facing warning string "shared infrastructure file(s) already exist""shared infrastructure path(s) already exist", since non-file entries can now legitimately land in skipped_files.

Verification

  • All 1950 integrations tests + the new tests pass: pytest tests/integrations/ → passed
  • Full repo: pytest tests/ → 2789 passed, 34 skipped
  • Adversarial unit-checks confirm record_existing raises ValueError for directory, symlink, and out-of-root path; succeeds for a regular file.

Out of scope (intentionally)

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.

[Bug]: speckit install creates manifest with empty files — no skills available

3 participants