Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 17 additions & 15 deletions .claude/agents/pack-validator.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,27 @@ You are a pack-validation specialist for navi-bootstrap. Your job: confirm that
git diff --name-only origin/main...HEAD | grep '^packs/' | cut -d'/' -f2 | sort -u
```

2. For each changed pack, run the full validation chain:
2. For each changed pack, run the full validation chain **including** its
pack-specific tests inside the same loop body — otherwise `pack_snake`
takes the value of the last iteration only and earlier packs' tests are
silently skipped:

```bash
uv run nboot validate --spec nboot-spec.json
scratch=$(mktemp -d -t nboot-scratch-XXXX)
uv run nboot new "$scratch"
uv run nboot apply --spec nboot-spec.json --pack <PACK> --target "$scratch"
uv run nboot diff --spec nboot-spec.json --pack <PACK> --target "$scratch"
for PACK in $(git diff --name-only origin/main...HEAD | grep '^packs/' | cut -d'/' -f2 | sort -u); do
uv run nboot validate --spec nboot-spec.json
scratch=$(mktemp -d -t nboot-scratch-XXXX)
uv run nboot new "$scratch"
Comment on lines +25 to +26

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

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

The validation snippet creates an absolute scratch path via mktemp -d ... and then runs nboot new "$scratch", but nboot new rejects names containing path separators (see src/navi_bootstrap/cli.py:new), so this command chain will fail as written. Adjust the instructions to create a scratch parent directory and run nboot new <project-name> inside it (or otherwise ensure the name argument is a single path segment), then point --target at the created project directory.

Suggested change
scratch=$(mktemp -d -t nboot-scratch-XXXX)
uv run nboot new "$scratch"
scratch_parent=$(mktemp -d -t nboot-scratch-XXXX)
scratch_name=scratch
scratch="$scratch_parent/$scratch_name"
(
cd "$scratch_parent" &&
uv run nboot new "$scratch_name"
)

Copilot uses AI. Check for mistakes.
uv run nboot apply --spec nboot-spec.json --pack "$PACK" --target "$scratch"
uv run nboot diff --spec nboot-spec.json --pack "$PACK" --target "$scratch"

# Pack-specific test for this iteration's pack
pack_snake=$(echo "$PACK" | tr '-' '_')
uv run pytest tests/test_${pack_snake}_pack.py -v || \
uv run pytest tests/ -k "$pack_snake" -v
done
```

3. Run the pack-specific test:

```bash
pack_snake=$(echo <PACK> | tr '-' '_')
uv run pytest tests/test_${pack_snake}_pack.py -v 2>/dev/null || \
uv run pytest tests/ -k "$pack_snake" -v
```

4. Run cross-cutting tests that commonly break on pack changes:
3. Run cross-cutting tests that commonly break on pack changes:

```bash
uv run pytest tests/test_engine.py tests/test_manifest.py tests/test_integration.py -v
Expand Down
26 changes: 21 additions & 5 deletions src/navi_bootstrap/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,21 @@ def render_cmd(

if out is None:
name = spec_data["name"]
if not name or "/" in name or "\\" in name:
stripped_name = name.strip() if isinstance(name, str) else ""
if (
not stripped_name
or "/" in stripped_name
or "\\" in stripped_name
or ".." in stripped_name
or stripped_name.startswith(".")
):
raise click.ClickException(
f"Unsafe spec name {name!r} cannot be used as output directory. "
"Name must be a non-empty single path segment, must not start with "
"'.', and must not contain '..' or path separators. "
"Use --out to specify an explicit output path."
)
output_dir = Path(name)
output_dir = Path(stripped_name)
else:
Comment on lines 120 to 137

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

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

PR description mentions tightening spec-name validation for apply/diff, but the only spec-name-as-path validation appears under the render command (render_cmd). If apply/diff also need similar hardening, it looks missing; otherwise, the PR description should be updated to reference render instead.

Copilot uses AI. Check for mistakes.
output_dir = out

Expand Down Expand Up @@ -577,12 +586,19 @@ def new(
) -> None:
"""Create a new Python project with operational infrastructure."""
# Validate name before using as path
if not name or "/" in name or "\\" in name or name.startswith("."):
stripped_name = name.strip() if name else ""
if (
not stripped_name
or "/" in stripped_name
or "\\" in stripped_name
or ".." in stripped_name
or stripped_name.startswith(".")
):
raise click.ClickException(
f"Unsafe project name {name!r}. "
"Names must not contain path separators or start with a dot."
"Names must not contain path separators, '..', or start with a dot."

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

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

The new() validation rejects empty/whitespace-only names (not stripped_name), but the error message only mentions path separators/".."/dot prefix. Consider updating the message to also reflect the non-empty requirement (and that surrounding whitespace is stripped) so users get an accurate failure reason.

Suggested change
"Names must not contain path separators, '..', or start with a dot."
"Surrounding whitespace is stripped, and the resulting name must not be empty, "
"contain path separators, contain '..', or start with a dot."

Copilot uses AI. Check for mistakes.
)
output_dir = Path(name)
output_dir = Path(stripped_name)
Comment on lines +589 to +601

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

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

new() strips whitespace into stripped_name for output_dir, but later uses the original name when building the spec (build_spec_for_new(name, ...)). If the user passes leading/trailing whitespace, the directory created and the spec’s internal name (and derived paths like src_dir) can diverge, potentially creating unexpected paths in the generated project. Consider normalizing once (e.g., replace name with stripped_name) and using the normalized value consistently for both output_dir and the spec construction.

Copilot uses AI. Check for mistakes.
if output_dir.exists():
raise click.ClickException(
f"Directory {name!r} already exists. nboot new is for greenfield projects only."
Expand Down
4 changes: 3 additions & 1 deletion tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@ def test_reports_failures_without_stopping(self, mock_run: MagicMock, tmp_path:
assert not results[0].success
assert results[1].success

def test_empty_hooks(self, tmp_path: Path) -> None:
@patch("navi_bootstrap.hooks.subprocess.run")
def test_empty_hooks(self, mock_run: MagicMock, tmp_path: Path) -> None:
results = run_hooks([], tmp_path)
assert results == []
mock_run.assert_not_called()

@patch("navi_bootstrap.hooks.subprocess.run")
def test_captures_output(self, mock_run: MagicMock, tmp_path: Path) -> None:
Expand Down
1 change: 1 addition & 0 deletions tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ def test_detects_async_test_functions(self, tmp_path: Path) -> None:
test_file = tests / "test_example.py"
test_file.write_text("def test_sync(): pass\nasync def test_async(): pass\n")
result = detect_test_info(tmp_path)
assert result["test_framework"] == "pytest"
assert result["test_count"] == 2


Expand Down
4 changes: 3 additions & 1 deletion tests/test_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ def test_warnings_accepted(self, mock_run: MagicMock, tmp_path: Path) -> None:
assert len(results) == 1
assert results[0].passed

def test_empty_validations(self, tmp_path: Path) -> None:
@patch("navi_bootstrap.validate.subprocess.run")
def test_empty_validations(self, mock_run: MagicMock, tmp_path: Path) -> None:
results = run_validations([], tmp_path)
assert results == []
mock_run.assert_not_called()

@patch("navi_bootstrap.validate.subprocess.run")
def test_skips_method_based_validations(self, mock_run: MagicMock, tmp_path: Path) -> None:
Expand Down
Loading