diff --git a/.github/agents/data-science/test-streamlit-dashboard.agent.md b/.github/agents/data-science/test-streamlit-dashboard.agent.md index 13b0b37c4..1874e1925 100644 --- a/.github/agents/data-science/test-streamlit-dashboard.agent.md +++ b/.github/agents/data-science/test-streamlit-dashboard.agent.md @@ -17,7 +17,7 @@ Confirm prerequisites and prepare the test environment. 2. Verify Playwright and pytest-playwright are installed. Install if missing: ```bash - pip install playwright pytest-playwright pytest-asyncio + uv pip install playwright pytest-playwright pytest-asyncio playwright install chromium ``` @@ -107,9 +107,13 @@ async def test_page_navigation(page): """Test sidebar navigation functionality""" await page.goto("http://localhost:8501") - pages = ["📊 Summary Statistics", "📈 Univariate Analysis", - "🔗 Multivariate Analysis", "⏰ Time Series Analysis", - "💬 Chat Interface"] + pages = [ + "📊 Summary Statistics", + "📈 Univariate Analysis", + "🔗 Multivariate Analysis", + "⏰ Time Series Analysis", + "💬 Chat Interface", + ] for page_name in pages: await page.select_option("select", page_name) diff --git a/.github/instructions/coding-standards/python-script.instructions.md b/.github/instructions/coding-standards/python-script.instructions.md index 8f46132bf..95609d6dd 100644 --- a/.github/instructions/coding-standards/python-script.instructions.md +++ b/.github/instructions/coding-standards/python-script.instructions.md @@ -9,7 +9,7 @@ Conventions for Python 3.11+ scripts used in automation, tooling, and CLI applic ## Environment and Dependency Management -1. **Never use `pip install` directly.** All package management must be handled using `uv` (e.g., `uv add `). +1. **Never use `pip install` directly.** All package management must be handled using `uv` (e.g., `uv add `). 2. **Never run Python scripts or tools outside a virtual environment.** Always execute scripts via `uv run ` or ensure the `.venv` is activated before running. 3. Ensure a `.venv` exists in the project root before executing any Python code. If starting from scratch, refer to the `uv-projects.instructions.md` file for environment setup. @@ -21,7 +21,7 @@ import sys EXIT_SUCCESS = 0 # Successful execution EXIT_FAILURE = 1 # General failure -EXIT_ERROR = 2 # Arguments or configuration error +EXIT_ERROR = 2 # Arguments or configuration error def main() -> int: @@ -121,13 +121,17 @@ import os from pathlib import Path -def run_command(cmd: list[str], cwd: Path | None = None, extra_env: dict[str, str] | None = None) -> str: +def run_command( + cmd: list[str], cwd: Path | None = None, extra_env: dict[str, str] | None = None +) -> str: """Run command and return stdout, raising on failure.""" env = os.environ.copy() if extra_env: env.update(extra_env) try: - result = subprocess.run(cmd, capture_output=True, text=True, check=True, cwd=cwd, env=env) + result = subprocess.run( + cmd, capture_output=True, text=True, check=True, cwd=cwd, env=env + ) return result.stdout except subprocess.CalledProcessError as e: logger.error("Command failed: %s\nstderr: %s", e.returncode, e.stderr) diff --git a/.github/instructions/coding-standards/uv-projects.instructions.md b/.github/instructions/coding-standards/uv-projects.instructions.md index 3ae1f0dc6..d1736f19d 100644 --- a/.github/instructions/coding-standards/uv-projects.instructions.md +++ b/.github/instructions/coding-standards/uv-projects.instructions.md @@ -9,7 +9,7 @@ You are a Python environment specialist focused on uv virtual environment manage ## Strict Constraints -1. **Never use `pip install` directly.** Always use `uv add ` for all package management (adding, removing, locking, and syncing dependencies). +1. **Never use `pip install` directly.** Always use `uv add ` for all package management (adding, removing, locking, and syncing dependencies). 2. **Never run Python scripts or tools outside a virtual environment.** Always execute scripts via `uv run ` or ensure the `.venv` is activated first. 3. **Verify `.venv` existence:** Before any Python work, verify a `.venv` exists in the project root. If not, create one with `uv init` and `uv sync`. 4. **Migration Path:** If a `requirements.txt` exists but no `pyproject.toml`, migrate it by running `uv init` and then `uv add -r requirements.txt`. diff --git a/.github/instructions/experimental/pptx.instructions.md b/.github/instructions/experimental/pptx.instructions.md index e06a365c5..4a34aed30 100644 --- a/.github/instructions/experimental/pptx.instructions.md +++ b/.github/instructions/experimental/pptx.instructions.md @@ -60,7 +60,7 @@ Include `` at the top of all markdown files cr * For update and cleanup workflows, preserve existing masters and layouts from the source deck. * When updating an existing deck, always regenerate from content YAML rather than modifying the PPTX directly; update content files first, then regenerate into `slide-deck/`. * Follow the repo's Python environment conventions (`uv-projects.instructions.md`) for virtual environment and dependency management. -* All dependencies are declared in `pyproject.toml` at the skill root. The `Invoke-PptxPipeline.ps1` orchestrator manages the virtual environment automatically. Never install packages with `pip install` directly. +* All dependencies are declared in `pyproject.toml` at the skill root. The `Invoke-PptxPipeline.ps1` orchestrator manages the virtual environment automatically. Never install packages with `pip install` directly. * When scripts fail due to missing modules or import errors, follow the Environment Recovery steps in the `powerpoint` skill instructions. ### Build Mode: `--template` vs `--source` diff --git a/.github/prompts/data-science/synth-data-generate.prompt.md b/.github/prompts/data-science/synth-data-generate.prompt.md index 1dda22d88..7ea85dd4e 100644 --- a/.github/prompts/data-science/synth-data-generate.prompt.md +++ b/.github/prompts/data-science/synth-data-generate.prompt.md @@ -138,7 +138,9 @@ day = np.random.choice(pd.date_range(start=start_date, end=end_date)) day = pd.Timestamp(day).date() # Ensures Python datetime.date hour = int(np.random.choice(range(8, 19))) minute = int(np.random.randint(0, 60)) -start_time = datetime.combine(day, datetime.min.time()) + timedelta(hours=hour, minutes=minute) +start_time = datetime.combine(day, datetime.min.time()) + timedelta( + hours=hour, minutes=minute +) ``` ### Data Types & Ranges @@ -352,4 +354,4 @@ print(f"\\nGeneration timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" weather_12_states_12_months/ ├── synth_weather_12_states_12_months.ipynb └── synthetic_weather_12_states_12_months_data.csv -``` \ No newline at end of file +``` diff --git a/.github/prompts/design-thinking/dt-canonical-deck.prompt.md b/.github/prompts/design-thinking/dt-canonical-deck.prompt.md index 0518c9a3e..71c12c107 100644 --- a/.github/prompts/design-thinking/dt-canonical-deck.prompt.md +++ b/.github/prompts/design-thinking/dt-canonical-deck.prompt.md @@ -108,7 +108,7 @@ Use the bash script instead. Verify the bash script flags by sending `invoke-ppt - Use `send_to_terminal` to send commands to the active terminal - Use `get_terminal_output` to poll for completion -- Do not run `pip install` or manual dependency installation +- Do not run `pip install` or manual dependency installation - Rely on PowerPoint skill environment setup (`uv sync`) and documented prerequisites - Keep output under the project slug render directory diff --git a/.github/skills/experimental/customer-card-render/SKILL.md b/.github/skills/experimental/customer-card-render/SKILL.md index 52ecbe284..1c626dd6c 100644 --- a/.github/skills/experimental/customer-card-render/SKILL.md +++ b/.github/skills/experimental/customer-card-render/SKILL.md @@ -38,7 +38,7 @@ For full PowerPoint pipeline documentation, see [powerpoint/SKILL.md](../powerpo powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" # Via pip (fallback) - pip install uv + pip install uv ``` * The experimental `powerpoint` skill at `.github/skills/experimental/powerpoint/` for the `Invoke-PptxPipeline.ps1` build step @@ -152,11 +152,11 @@ For complete mapping details, see [references/mapping-spec.md](references/mappin ## Troubleshooting -| Issue | Cause | Solution | -|---------------------------------|--------------------------------------------|------------------------------------------------------------------------------------------| -| `uv` not found | uv not installed | Run `curl -LsSf https://astral.sh/uv/install.sh \| sh` (macOS/Linux) or `pip install uv` | -| Python not found by uv | No Python 3.11+ on PATH | Run `uv python install 3.11` | -| Template not found | `--canonical-dir` contains unknown type | Check frontmatter `type:` field against supported artifact types | -| Empty output directory | No canonical markdown files found | Confirm `--canonical-dir` path and that files have `---` frontmatter | -| PPTX build fails after generate | PowerPoint skill missing or path incorrect | Confirm `powerpoint/` skill exists at `.github/skills/experimental/powerpoint/` | +| Issue | Cause | Solution | +|---------------------------------|--------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| `uv` not found | uv not installed | Run `curl -LsSf https://astral.sh/uv/install.sh \| sh` (macOS/Linux) or `pip install uv` | +| Python not found by uv | No Python 3.11+ on PATH | Run `uv python install 3.11` | +| Template not found | `--canonical-dir` contains unknown type | Check frontmatter `type:` field against supported artifact types | +| Empty output directory | No canonical markdown files found | Confirm `--canonical-dir` path and that files have `---` frontmatter | +| PPTX build fails after generate | PowerPoint skill missing or path incorrect | Confirm `powerpoint/` skill exists at `.github/skills/experimental/powerpoint/` | diff --git a/.github/skills/experimental/powerpoint/SKILL.md b/.github/skills/experimental/powerpoint/SKILL.md index 41ee57f24..e23ca5dd8 100644 --- a/.github/skills/experimental/powerpoint/SKILL.md +++ b/.github/skills/experimental/powerpoint/SKILL.md @@ -6,7 +6,7 @@ compatibility: 'Requires uv, Python 3.11+, PowerShell 7+, and LibreOffice' metadata: authors: "microsoft/hve-core" spec_version: "1.0" - last_updated: "2026-03-18" + last_updated: "2026-07-31" --- # PowerPoint Skill @@ -37,7 +37,7 @@ curl -LsSf https://astral.sh/uv/install.sh | sh powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" # Via pip (fallback) -pip install uv +pip install uv ``` ### System Dependencies (Export and Validation) @@ -460,7 +460,7 @@ python scripts/embed_audio.py \ Embeds WAV audio files into PPTX slides. Audio files are matched to slides by naming convention (`slide-001.wav`, `slide-002.wav`, etc.). The audio icon is placed off-screen (below the slide boundary) to keep it hidden during presentation. Pass `--slides` to embed audio on specific slides only. -**Dependencies**: Requires `pillow` (`pip install pillow`) for poster frame generation. +**Dependencies**: Requires `pillow` (`uv pip install pillow`) for poster frame generation. > [!NOTE] > WAV files are embedded uncompressed. For large narrated decks, consider pre-compressing audio before embedding to manage PPTX file size. @@ -537,19 +537,19 @@ Re-check [NVD](https://nvd.nist.gov) and [OSV](https://osv.dev) advisories for M ## Troubleshooting -| Issue | Cause | Solution | -|----------------------------------------|----------------------------------------------------|--------------------------------------------------------------------------------------------------| -| SVG runtime error | python-pptx cannot embed SVG | Convert to PNG via `cairosvg` before adding | -| Text overlay between elements | Insufficient vertical spacing | Follow element positioning conventions in `pptx.instructions.md` | -| Width overflow off-slide | Element extends beyond slide boundary | Follow element positioning conventions in `pptx.instructions.md` | -| Bright accent color unreadable as fill | White text on bright background | Darken accent to ~60% saturation for box fills | -| Background fill replaced with NoFill | Accessed `background.fill` on inherited background | Check `slide.follow_master_background` before accessing | -| Missing speaker notes | Notes not specified in `content.yaml` | Add `speaker_notes` field to every content slide | -| LibreOffice not found during Validate | Validate exports slides to images first | Install LibreOffice: `brew install --cask libreoffice` (macOS) | -| `uv` not found | uv package manager not installed | Install uv: `curl -LsSf https://astral.sh/uv/install.sh \| sh` (macOS/Linux) or `pip install uv` | -| Python not found by uv | No Python 3.11+ on PATH | Install via `uv python install 3.11` or `pyenv install 3.11` | -| `uv sync` fails | Missing or corrupt `.venv` | Delete `.venv/` at the skill root and re-run `uv sync` | -| Import errors in scripts | Dependencies not installed or stale venv | Run `uv sync` from the skill root to recreate the environment | +| Issue | Cause | Solution | | +|----------------------------------------|----------------------------------------------------|--------------------------------------------------------------------------------------------------|-------------------------| +| SVG runtime error | python-pptx cannot embed SVG | Convert to PNG via `cairosvg` before adding | | +| Text overlay between elements | Insufficient vertical spacing | Follow element positioning conventions in `pptx.instructions.md` | | +| Width overflow off-slide | Element extends beyond slide boundary | Follow element positioning conventions in `pptx.instructions.md` | | +| Bright accent color unreadable as fill | White text on bright background | Darken accent to ~60% saturation for box fills | | +| Background fill replaced with NoFill | Accessed `background.fill` on inherited background | Check `slide.follow_master_background` before accessing | | +| Missing speaker notes | Notes not specified in `content.yaml` | Add `speaker_notes` field to every content slide | | +| LibreOffice not found during Validate | Validate exports slides to images first | Install LibreOffice: `brew install --cask libreoffice` (macOS) | | +| `uv` not found | uv package manager not installed | Install uv: `curl -LsSf https://astral.sh/uv/install.sh \| sh` (macOS/Linux) or `pip install uv` | | +| Python not found by uv | No Python 3.11+ on PATH | Install via `uv python install 3.11` or `pyenv install 3.11` | | +| `uv sync` fails | Missing or corrupt `.venv` | Delete `.venv/` at the skill root and re-run `uv sync` | | +| Import errors in scripts | Dependencies not installed or stale venv | Run `uv sync` from the skill root to recreate the environment | | ## Environment Recovery diff --git a/.github/skills/experimental/powerpoint/scripts/export_slides.py b/.github/skills/experimental/powerpoint/scripts/export_slides.py index a97c55c06..0a70bb0a8 100644 --- a/.github/skills/experimental/powerpoint/scripts/export_slides.py +++ b/.github/skills/experimental/powerpoint/scripts/export_slides.py @@ -161,7 +161,7 @@ def filter_pdf_pages(pdf_path: Path, pages: list[int], output_path: Path) -> Pat import fitz # noqa: PLC0415 — PyMuPDF except ImportError: logger.error( - "PyMuPDF is required for slide filtering. Install via: pip install pymupdf" + "PyMuPDF is required for slide filtering. Install via: uv pip install pymupdf" ) sys.exit(EXIT_FAILURE) diff --git a/.github/skills/experimental/powerpoint/scripts/export_svg.py b/.github/skills/experimental/powerpoint/scripts/export_svg.py index 1e0533563..672243024 100644 --- a/.github/skills/experimental/powerpoint/scripts/export_svg.py +++ b/.github/skills/experimental/powerpoint/scripts/export_svg.py @@ -173,7 +173,7 @@ def export_pdf_to_svg( import fitz # noqa: F401, PLC0415 — PyMuPDF availability check except ImportError as e: raise PyMuPDFError( - "PyMuPDF is required for SVG export. Install via: pip install pymupdf" + "PyMuPDF is required for SVG export. Install via: uv pip install pymupdf" ) from e try: diff --git a/.github/skills/experimental/powerpoint/scripts/render_pdf_images.py b/.github/skills/experimental/powerpoint/scripts/render_pdf_images.py index d1ebef071..6b3167dfa 100644 --- a/.github/skills/experimental/powerpoint/scripts/render_pdf_images.py +++ b/.github/skills/experimental/powerpoint/scripts/render_pdf_images.py @@ -98,7 +98,7 @@ def render_pages( try: import fitz # noqa: F401, PLC0415 — PyMuPDF availability check except ImportError: - logger.error("PyMuPDF is required. Install via: pip install pymupdf") + logger.error("PyMuPDF is required. Install via: uv pip install pymupdf") sys.exit(EXIT_FAILURE) output_dir.mkdir(parents=True, exist_ok=True) diff --git a/.github/workflows/beval.yml b/.github/workflows/beval.yml index a66ca61a3..b3096740a 100644 --- a/.github/workflows/beval.yml +++ b/.github/workflows/beval.yml @@ -41,11 +41,15 @@ jobs: npm ci --prefix evals/beval echo "${{ github.workspace }}/evals/beval/node_modules/.bin" >> "$GITHUB_PATH" + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + + - name: Install beval # beval is hosted under a personal account (vyta) while an org-owned # home is evaluated. The install is pinned to a specific commit SHA to # mitigate supply-chain risk in the interim. - run: pip install --no-cache-dir "beval[all] @ git+https://github.com/vyta/beval.git@d9f46c24f03b0b806d928a8a8ce2fc66d8e470fb#subdirectory=python" + run: uv pip install --system --no-cache-dir "beval[all] @ git+https://github.com/vyta/beval.git@d9f46c24f03b0b806d928a8a8ce2fc66d8e470fb#subdirectory=python" - name: Start agent (TCP) env: diff --git a/.github/workflows/pip-install-lint.yml b/.github/workflows/pip-install-lint.yml new file mode 100644 index 000000000..e6aca34ac --- /dev/null +++ b/.github/workflows/pip-install-lint.yml @@ -0,0 +1,26 @@ +name: Pip Install Lint + +on: + workflow_call: + inputs: + soft-fail: + description: 'Whether to continue on bare pip install violations' # pip-install-ok + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + check-bare-pip-install: + name: Check for bare pip install + runs-on: ubuntu-latest + permissions: + contents: read + continue-on-error: ${{ inputs.soft-fail }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Run bare pip install lint check + run: pwsh -File scripts/linting/Invoke-PipInstallLint.ps1 diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 398209132..c311edc13 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -27,6 +27,14 @@ jobs: with: soft-fail: false + pip-install-lint: + name: Pip Install Lint + uses: ./.github/workflows/pip-install-lint.yml + permissions: + contents: read + with: + soft-fail: false + markdown-lint: name: Markdown Lint uses: ./.github/workflows/markdown-lint.yml @@ -485,6 +493,7 @@ jobs: - node-tests - fuzz-tests - pip-audit + - pip-install-lint - docusaurus-tests - frontmatter-validation - adr-consistency-validation diff --git a/scripts/linting/Invoke-PipInstallLint.ps1 b/scripts/linting/Invoke-PipInstallLint.ps1 new file mode 100644 index 000000000..4c19eff83 --- /dev/null +++ b/scripts/linting/Invoke-PipInstallLint.ps1 @@ -0,0 +1,114 @@ +# Copyright (c) 2026 Microsoft Corporation. All rights reserved. +# SPDX-License-Identifier: MIT + +<# +.SYNOPSIS +Lint script to detect bare 'pip install' calls. +The repository follows a 'uv-first' Python convention. +#> + +param( + [string]$TestDirectory = "." +) + +$ErrorActionPreference = "Stop" + +$script:ExcludeDirs = @(".git", "evals", ".venv", "venv", "env", "node_modules", "__pycache__") +$script:ExcludeFiles = @("THIRD-PARTY-NOTICES", "Invoke-PipInstallLint.ps1", "Invoke-PipInstallLint.Tests.ps1") +$script:Violations = @() +$script:ScannedFiles = @{} + +function script:Test-ExcludedPath { + param([string]$Path) + $normalizedPath = $Path.Replace("\", "/").ToLowerInvariant() + + foreach ($dir in $script:ExcludeDirs) { + if ($normalizedPath -match "(^|/)$([regex]::Escape($dir))(/|$)") { return $true } + } + foreach ($file in $script:ExcludeFiles) { + if ($normalizedPath -match "(^|/)$([regex]::Escape($file))(/|$)") { return $true } + } + return $false +} + +function script:Invoke-FileScan { + param([string]$FilePath) + + $normalizedPath = $FilePath.Replace("\", "/") + if ($script:ScannedFiles.ContainsKey($normalizedPath)) { return } + $script:ScannedFiles[$normalizedPath] = $true + + if (script:Test-ExcludedPath -Path $FilePath) { return } + + $ext = [System.IO.Path]::GetExtension($FilePath).ToLowerInvariant() + if ($ext -notin @(".py", ".ps1", ".yml", ".yaml", ".md", "")) { return } + + try { + $lines = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $lines) { return } + + $lineNumber = 1 + foreach ($line in $lines -split "`r?`n") { + $strippedLine = $line.Trim() + + if ([string]::IsNullOrWhiteSpace($strippedLine)) { + $lineNumber++ + continue + } + + if ($line -match "#\s*pip-install-ok\b" -or $line -match "") { + $lineNumber++ + continue + } + + if ($line -match "\bpip3?\s+install\b" -and $line -notmatch "\buv\s+pip3?\s+install\b" -and $line -notmatch "%pip\s+install") { + if ($strippedLine -notmatch "^(name:|- name:)") { + $script:Violations += "$FilePath`:$lineNumber`: $strippedLine" + } + } + $lineNumber++ + } + } + catch { + Write-Warning "Could not read $FilePath`: $_" + } +} + +function script:Invoke-Lint { + param([string]$TargetDir = ".") + + $script:Violations = @() + $script:ScannedFiles = @{} + + if ($TargetDir -eq ".") { + foreach ($dir in @(".github/workflows", "scripts")) { + if (Test-Path $dir) { + Get-ChildItem -Path $dir -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object { script:Invoke-FileScan -FilePath $_.FullName } + } + } + Get-ChildItem -Path "." -Recurse -Include *.py, *.ps1, *.yml, *.yaml, *.md -File -ErrorAction SilentlyContinue | ForEach-Object { + script:Invoke-FileScan -FilePath $_.FullName + } + } else { + Get-ChildItem -Path $TargetDir -Recurse -Include *.py, *.ps1, *.yml, *.yaml, *.md -File -ErrorAction SilentlyContinue | ForEach-Object { + script:Invoke-FileScan -FilePath $_.FullName + } + } + + if ($script:Violations.Count -gt 0) { + Write-Error "ERROR: Found bare 'pip install' calls. Use 'uv pip install' instead." + Write-Host "The repo follows a uv-first Python convention.`n" -ForegroundColor Yellow + foreach ($v in ($script:Violations | Sort-Object -Unique)) { + Write-Host " - $v" -ForegroundColor Red + } + return $false + } else { + Write-Host "Success: No bare 'pip install' calls found." -ForegroundColor Green + return $true + } +} + +if ($MyInvocation.InvocationName -ne '.') { + $success = script:Invoke-Lint -TargetDir $TestDirectory + if (-not $success) { exit 1 } +} diff --git a/scripts/tests/linting/Invoke-PipInstallLint.Tests.ps1 b/scripts/tests/linting/Invoke-PipInstallLint.Tests.ps1 new file mode 100644 index 000000000..5a5aaa362 --- /dev/null +++ b/scripts/tests/linting/Invoke-PipInstallLint.Tests.ps1 @@ -0,0 +1,119 @@ +# Copyright (c) 2026 Microsoft Corporation. All rights reserved. +# SPDX-License-Identifier: MIT + +Describe "Invoke-PipInstallLint.ps1" { + BeforeAll { + $scriptPath = "$PSScriptRoot/../../linting/Invoke-PipInstallLint.ps1" + . $scriptPath + $testDir = "$PSScriptRoot/TestLintDir" + if (Test-Path $testDir) { Remove-Item -Recurse -Force $testDir } + New-Item -ItemType Directory -Path $testDir | Out-Null + } + + AfterAll { + if (Test-Path $testDir) { Remove-Item -Recurse -Force $testDir } + } + + AfterEach { + if (Test-Path $testDir) { Remove-Item -Recurse -Force $testDir } + New-Item -ItemType Directory -Path $testDir | Out-Null + } + + It "Should pass on clean state" { + $testFile = Join-Path $testDir "clean.py" + Set-Content -Path $testFile -Value "print('hello world')" + + $result = script:Invoke-Lint -TargetDir $testDir + $result | Should -Be $true + $script:Violations.Count | Should -Be 0 + } + + It "Should detect bare pip install violation" { + $testFile = Join-Path $testDir "violation.yml" + Set-Content -Path $testFile -Value "run: pip install malicious-package" + + # Invoke-Lint calls Write-Error on violations, which throws under $ErrorActionPreference=Stop + { script:Invoke-Lint -TargetDir $testDir } | Should -Throw -ExpectedMessage "*bare 'pip install'*" + $script:Violations.Count | Should -BeGreaterThan 0 + $script:Violations[0] | Should -Match "malicious-package" + } + + It "Should respect exclusion logic (evals directory)" { + $evalsDir = Join-Path $testDir "evals" + New-Item -ItemType Directory -Path $evalsDir | Out-Null + $testFile = Join-Path $evalsDir "fake_eval_test.py" + Set-Content -Path $testFile -Value "run: pip install mock-package" + + $result = script:Invoke-Lint -TargetDir $testDir + $result | Should -Be $true + $script:Violations.Count | Should -Be 0 + } + + It "Should allow uv pip install" { + $testFile = Join-Path $testDir "uv_allowed.py" + Set-Content -Path $testFile -Value "run: uv pip install fastapi" + + $result = script:Invoke-Lint -TargetDir $testDir + $result | Should -Be $true + $script:Violations.Count | Should -Be 0 + } + + It "Should respect inline ignore marker for Python/YAML" { + $testFile = Join-Path $testDir "ignored.py" + Set-Content -Path $testFile -Value "run: pip install legacy-package # pip-install-ok" + + $result = script:Invoke-Lint -TargetDir $testDir + $result | Should -Be $true + $script:Violations.Count | Should -Be 0 + } + + It "Should respect inline ignore marker for Markdown" { + $testFile = Join-Path $testDir "ignored.md" + Set-Content -Path $testFile -Value "run: pip install legacy-package " + + $result = script:Invoke-Lint -TargetDir $testDir + $result | Should -Be $true + $script:Violations.Count | Should -Be 0 + } + + It "Should scan correctly when TestDirectory uses default value" { + $testFile = Join-Path $testDir "default_param_test.py" + Set-Content -Path $testFile -Value "run: pip install default-violation" + + Push-Location $testDir + try { + { script:Invoke-Lint } | Should -Throw -ExpectedMessage "*bare 'pip install'*" + $script:Violations.Count | Should -BeGreaterThan 0 + $script:Violations[0] | Should -Match "default-violation" + } finally { + Pop-Location + } + } + + It "Should NOT exclude files with similar names (regex escape regression)" { + $testFile = Join-Path $testDir "Invoke-PipInstallLintXps1.py" + Set-Content -Path $testFile -Value "run: pip install wildcard-false-negative" + + { script:Invoke-Lint -TargetDir $testDir } | Should -Throw -ExpectedMessage "*bare 'pip install'*" + $script:Violations.Count | Should -BeGreaterThan 0 + $script:Violations[0] | Should -Match "wildcard-false-negative" + } + + It "Should still exclude exact filename match after regex escaping" { + $testFile = Join-Path $testDir "Invoke-PipInstallLint.ps1" + Set-Content -Path $testFile -Value "run: pip install should-be-excluded" + + $result = script:Invoke-Lint -TargetDir $testDir + $result | Should -Be $true + $script:Violations.Count | Should -Be 0 + } + + It "Should ignore %pip install (Jupyter magic command)" { + $testFile = Join-Path $testDir "jupyter_allowed.py" + Set-Content -Path $testFile -Value "%pip install pandas numpy" + + $result = script:Invoke-Lint -TargetDir $testDir + $result | Should -Be $true + $script:Violations.Count | Should -Be 0 + } +}