diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b1c942d..31de209 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,54 +3,34 @@ name: Build on: workflow_call: +permissions: + contents: read + jobs: cli: name: CLI + runs-on: ubuntu-latest + timeout-minutes: 30 + + container: + image: rust:1.89-bookworm steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 name: Checkout Code - - uses: actions/setup-python@v6 - name: Setup Python - - with: - python-version: "3.10" - - - run: python -m pip install --upgrade pip - name: Upgrade pip - - - run: python -m pip install -e '.[dev]' - name: Install Dependencies + - run: cargo build --release --locked + name: Build Release Binaries - - run: python -m pip install build twine - name: Install Build Tools + - run: target/release/agentskill --help + name: Test agentskill CLI - - run: python -m build --sdist --wheel --no-isolation - name: Build Package + - run: target/release/agsk --version + name: Test agsk CLI - - run: python -m twine check dist/* - name: Check Distribution - - - run: python -m venv /tmp/agentskill-build-venv - name: Create Virtual Environment - - - run: /tmp/agentskill-build-venv/bin/pip install dist/agsk-*.whl - name: Install Built Package - - - run: /tmp/agentskill-build-venv/bin/agentskill --help - name: Test CLI - - - run: /tmp/agentskill-build-venv/bin/agentskill analyze examples/python --pretty + - run: target/release/agentskill analyze agentskill-skill/examples/python --pretty name: Test Analyze - - run: /tmp/agentskill-build-venv/bin/agentskill generate examples/python > /tmp/generated-AGENTS.md + - run: target/release/agentskill generate agentskill-skill/examples/python > /tmp/generated-AGENTS.md name: Test Generate - - - uses: actions/upload-artifact@v6 - name: Upload Distribution - - with: - name: dist - path: dist/* diff --git a/.github/workflows/checksum.yml b/.github/workflows/checksum.yml new file mode 100644 index 0000000..bf6b6af --- /dev/null +++ b/.github/workflows/checksum.yml @@ -0,0 +1,46 @@ +name: Checksum + +on: + workflow_call: + inputs: + version: + required: true + type: string + +permissions: + contents: read + +jobs: + cli: + name: CLI + + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/download-artifact@v8 + name: Download Release Archives + + with: + pattern: agentskill-${{ inputs.version }}-* + path: dist + merge-multiple: true + + - run: | + set -euo pipefail + + find dist -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.zip' \) | sort > archives.txt + + test "$(wc -l < archives.txt)" -eq 6 + + (cd dist && while read -r archive; do sha256sum "$(basename "$archive")"; done < ../archives.txt > SHA256SUMS) + + name: Generate Checksums + + - uses: actions/upload-artifact@v7 + name: Upload Checksums + + with: + name: agentskill-${{ inputs.version }}-checksums + path: dist/SHA256SUMS + if-no-files-found: error diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 7164f80..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Deploy - -on: - workflow_call: - secrets: - PYPI_API_TOKEN: - required: true - -jobs: - pypi: - name: PyPI - runs-on: ubuntu-latest - - steps: - - uses: actions/download-artifact@v6 - name: Download Distribution - - with: - name: dist - path: dist - - - name: Deploy - uses: pypa/gh-action-pypi-publish@release/v1 - - with: - user: __token__ - attestations: false - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f2e50b9..f8b84c5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,6 +9,13 @@ on: pull_request: branches: [main] +permissions: + contents: read + +concurrency: + group: main-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: verify: name: Verify @@ -23,3 +30,7 @@ jobs: name: Test needs: build uses: ./.github/workflows/test.yml + + security: + name: Security + uses: ./.github/workflows/security.yml diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..7126a70 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,147 @@ +name: Package + +on: + workflow_call: + inputs: + ref: + required: true + type: string + + version: + required: true + type: string + +permissions: + contents: read + +jobs: + cli: + name: CLI + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + + strategy: + fail-fast: false + + matrix: + include: + - os: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + archive: tar.gz + + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + archive: tar.gz + + - os: macos-15-intel + target: x86_64-apple-darwin + archive: tar.gz + + - os: macos-15 + target: aarch64-apple-darwin + archive: tar.gz + + - os: windows-2025 + target: x86_64-pc-windows-msvc + archive: zip + + - os: windows-11-arm + target: aarch64-pc-windows-msvc + archive: zip + + steps: + - uses: actions/checkout@v7 + name: Checkout Code + + with: + ref: ${{ inputs.ref }} + + - uses: dtolnay/rust-toolchain@stable + name: Install Rust Toolchain + + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + name: Cache Cargo + + - run: cargo build --release --locked --target ${{ matrix.target }} + name: Build Release Binaries + + env: + CARGO_BUILD_JOBS: 4 + + - if: matrix.archive == 'tar.gz' + run: | + set -euo pipefail + + ./target/${{ matrix.target }}/release/agentskill --version + ./target/${{ matrix.target }}/release/agsk --version + + name: Smoke Test Unix Binaries + + - if: matrix.archive == 'zip' + shell: pwsh + + run: | + $ErrorActionPreference = "Stop" + + & "target/${{ matrix.target }}/release/agentskill.exe" --version + & "target/${{ matrix.target }}/release/agsk.exe" --version + + name: Smoke Test Windows Binaries + + - if: matrix.archive == 'tar.gz' + + run: | + set -euo pipefail + + version="${{ inputs.version }}" + target="${{ matrix.target }}" + package_dir="agentskill-${version}-${target}" + archive="${package_dir}.tar.gz" + + mkdir -p "$package_dir" + cp "target/${target}/release/agentskill" "$package_dir/" + cp "target/${target}/release/agsk" "$package_dir/" + cp LICENSE "$package_dir/" + + tar -czf "$archive" "$package_dir" + bash agentskill-scripts/verify-release-archive.sh "$archive" "$target" + + name: Package Unix Archive + + - if: matrix.archive == 'zip' + shell: pwsh + + run: | + $ErrorActionPreference = "Stop" + + $version = "${{ inputs.version }}" + $target = "${{ matrix.target }}" + $packageDir = "agentskill-$version-$target" + $archive = "$packageDir.zip" + + New-Item -ItemType Directory -Force -Path $packageDir | Out-Null + Copy-Item "target/$target/release/agentskill.exe" "$packageDir/" + Copy-Item "target/$target/release/agsk.exe" "$packageDir/" + Copy-Item "LICENSE" "$packageDir/" + + Compress-Archive -Path $packageDir -DestinationPath $archive + $verifyDir = Join-Path $env:RUNNER_TEMP "agentskill-release-verify-$target" + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $verifyDir + Expand-Archive -LiteralPath $archive -DestinationPath $verifyDir + + foreach ($required in @("agentskill.exe", "agsk.exe", "LICENSE")) { + if (-not (Get-ChildItem -Path $verifyDir -Recurse -File -Filter $required)) { throw "archive is missing $required" } + } + + name: Package Windows Archive + + - uses: actions/upload-artifact@v7 + name: Upload Release Archive + + with: + name: agentskill-${{ inputs.version }}-${{ matrix.target }} + path: agentskill-${{ inputs.version }}-${{ matrix.target }}.${{ matrix.archive }} + if-no-files-found: error diff --git a/.github/workflows/prepare.yml b/.github/workflows/prepare.yml new file mode 100644 index 0000000..f268e23 --- /dev/null +++ b/.github/workflows/prepare.yml @@ -0,0 +1,67 @@ +name: Prepare + +on: + workflow_call: + inputs: + tag: + required: true + type: string + + outputs: + version: + value: ${{ jobs.cli.outputs.version }} + + prerelease: + value: ${{ jobs.cli.outputs.prerelease }} + + tag: + value: ${{ jobs.cli.outputs.tag }} + +permissions: + contents: read + +jobs: + cli: + name: CLI + runs-on: ubuntu-latest + + outputs: + version: ${{ steps.version.outputs.version }} + prerelease: ${{ steps.version.outputs.prerelease }} + tag: ${{ steps.version.outputs.tag }} + + steps: + - uses: actions/checkout@v7 + name: Checkout Code + + with: + ref: ${{ inputs.tag }} + + - id: version + env: + RELEASE_TAG: ${{ inputs.tag }} + + run: | + set -euo pipefail + + tag="$RELEASE_TAG" + bash agentskill-scripts/release-notes.sh "$tag" release-notes.md + version="${tag%-rc.*}" + prerelease=false + if [[ "$tag" == *-rc.* ]]; then prerelease=true; fi + + { + echo "tag=$tag" + echo "version=$version" + echo "prerelease=$prerelease" + } >> "$GITHUB_OUTPUT" + + name: Validate Release Version + + - uses: actions/upload-artifact@v7 + name: Upload Release Notes + + with: + name: agentskill-release-notes-${{ steps.version.outputs.version }} + path: release-notes.md + if-no-files-found: error diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..50b86db --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,51 @@ +name: Publish + +on: + workflow_call: + inputs: + version: + required: true + type: string + + tag: + required: true + type: string + + prerelease: + required: true + type: string + +permissions: + contents: write + +jobs: + cli: + name: CLI + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/download-artifact@v8 + name: Download Release Notes + + with: + name: agentskill-release-notes-${{ inputs.version }} + path: release + + - uses: actions/download-artifact@v8 + name: Download Release Assets + + with: + pattern: agentskill-${{ inputs.version }}-* + path: release/assets + merge-multiple: true + + - uses: softprops/action-gh-release@v3 + name: Publish Release + + with: + tag_name: ${{ inputs.tag }} + name: ${{ inputs.tag }} + body_path: release/release-notes.md + prerelease: ${{ inputs.prerelease }} + files: release/assets/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa9dec2..2ae3c18 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,27 +2,65 @@ name: Release on: workflow_dispatch: + inputs: + tag: + description: Release tag, for example 2.0.0 or 2.0.0-rc.1 + required: true + type: string push: tags: ["*"] jobs: + prepare: + name: Prepare + uses: ./.github/workflows/prepare.yml + + with: + tag: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + verify: name: Verify + needs: prepare uses: ./.github/workflows/verify.yml - build: - name: Build - needs: verify - uses: ./.github/workflows/build.yml + with: + ref: ${{ needs.prepare.outputs.tag }} test: name: Test - needs: build + needs: [prepare, verify] uses: ./.github/workflows/test.yml - deploy: - name: Deploy - needs: test - secrets: inherit - uses: ./.github/workflows/deploy.yml + with: + ref: ${{ needs.prepare.outputs.tag }} + + package: + name: Package + needs: [prepare, test] + uses: ./.github/workflows/package.yml + + with: + ref: ${{ needs.prepare.outputs.tag }} + version: ${{ needs.prepare.outputs.version }} + + checksum: + name: Checksum + needs: [prepare, package] + uses: ./.github/workflows/checksum.yml + + with: + version: ${{ needs.prepare.outputs.version }} + + publish: + name: Publish + needs: [prepare, checksum] + uses: ./.github/workflows/publish.yml + + permissions: + contents: write + + with: + tag: ${{ needs.prepare.outputs.tag }} + version: ${{ needs.prepare.outputs.version }} + prerelease: ${{ needs.prepare.outputs.prerelease }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..204e414 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,31 @@ +name: Security + +on: + workflow_call: + + workflow_dispatch: + + schedule: + - cron: "23 4 * * 1" + +permissions: + contents: read + +jobs: + dependencies: + name: Dependencies + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v7 + name: Checkout Code + + - uses: rustsec/audit-check@858dc40f52ca2b8570b7a997c1c4e35c6fc9a432 + name: Audit Vulnerabilities + + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: EmbarkStudios/cargo-deny-action@v2 + name: Check Dependency Policy diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 40e3aa6..f1e70a7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,27 +2,74 @@ name: Test on: workflow_call: + inputs: + ref: + required: false + type: string + default: "" + +permissions: + contents: read jobs: - cli: - name: CLI + container: + name: Container runs-on: ubuntu-latest + timeout-minutes: 30 + + container: + image: rust:1.89-bookworm steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 name: Checkout Code - - uses: actions/setup-python@v6 - name: Setup Python + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Install Git + run: | + apt-get update + apt-get install --no-install-recommends --yes git + git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - run: cargo install cargo-llvm-cov + name: Install Coverage Tool + + - run: CARGO_BUILD_JOBS=4 cargo llvm-cov --fail-under-lines 80 --workspace --locked + name: Run Tests and Coverage + + native: + name: ${{ matrix.label }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + + strategy: + fail-fast: false + + matrix: + include: + - os: ubuntu-latest + label: Ubuntu + - os: macos-latest + label: macOS + - os: windows-latest + label: Windows + + steps: + - uses: actions/checkout@v7 + name: Checkout Code with: - python-version: "3.10" + ref: ${{ inputs.ref || github.ref }} - - run: python -m pip install --upgrade pip - name: Upgrade Pip + - uses: dtolnay/rust-toolchain@stable + name: Install Rust Toolchain - - run: python -m pip install -e '.[dev]' - name: Install Dependencies + - uses: Swatinem/rust-cache@v2 + name: Cache Cargo - - run: pytest - name: Run Tests + - run: cargo test --workspace --locked + env: + CARGO_BUILD_JOBS: "4" + name: Run Workspace Tests diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index c56f377..991174a 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -2,33 +2,90 @@ name: Verify on: workflow_call: + inputs: + ref: + required: false + type: string + default: "" + +permissions: + contents: read jobs: + workflow-lint: + name: Workflow And Script Lint + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v7 + name: Checkout Code + + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Install Actionlint And Shellcheck + shell: bash + + run: | + set -euo pipefail + + sudo apt-get update + sudo apt-get install --no-install-recommends --yes shellcheck + mkdir -p "$RUNNER_TEMP/actionlint" + bash <(curl --fail --silent --show-error --location https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash) 1.7.12 "$RUNNER_TEMP/actionlint" + echo "$RUNNER_TEMP/actionlint" >> "$GITHUB_PATH" + + - name: Check GitHub Actions Workflows + run: actionlint -color + + - name: Check Release Scripts + run: shellcheck --shell=bash agentskill-scripts/*.sh + cli: name: CLI runs-on: ubuntu-latest + timeout-minutes: 30 + + container: + image: rust:1.89-bookworm steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 name: Checkout Code - - uses: actions/setup-python@v6 - name: Setup Python - with: - python-version: "3.10" + ref: ${{ inputs.ref || github.ref }} + + - run: rustup component add rustfmt clippy + name: Install Rust Components + + - uses: Swatinem/rust-cache@v2 + name: Cache Cargo + + - run: cargo fmt --all -- --check + name: Check Formatting - - run: python -m pip install --upgrade pip - name: Upgrade Pip + - run: CARGO_BUILD_JOBS=4 cargo clippy --workspace --all-targets --locked -- -D warnings + name: Lint Code - - run: python -m pip install -e '.[dev]' - name: Install Dependencies + - run: CARGO_BUILD_JOBS=4 cargo check --workspace --locked + name: Check Compilation - - run: ruff format --check . - name: Check Code Formatting + msrv: + name: MSRV + runs-on: ubuntu-latest + timeout-minutes: 20 + + container: + image: rust:1.89-bookworm + + steps: + - uses: actions/checkout@v7 + name: Checkout Code - - run: ruff check . - name: Check Code Linting + with: + ref: ${{ inputs.ref || github.ref }} - - run: mypy - name: Check Type Hints + - run: CARGO_BUILD_JOBS=4 cargo check --workspace --locked + name: Check MSRV Compilation diff --git a/.gitignore b/.gitignore index b62557b..c2deb34 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,6 @@ -scripts/__pycache__/ -__pycache__/ -*.pyc -.venv/ -.ruff_cache/ -.pytest_cache/ -.codex -.coverage -agentskill.egg-info/ +/target/ +/.cargo/ dist/ build/ -.mypy_cache/ -*.egg-info/ -*.egg +.agentskill/ +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 8519511..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,27 +0,0 @@ -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 - - hooks: - - id: end-of-file-fixer - - id: trailing-whitespace - - id: check-merge-conflict - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.11 - - hooks: - - id: ruff-format - - id: ruff - - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.18.2 - - hooks: - - id: mypy - - additional_dependencies: - - tomli>=2.4.1 - - args: [] - pass_filenames: false diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 8ad902f..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "python-envs.defaultEnvManager": "ms-python.python:pyenv" -} diff --git a/AGENTS.md b/AGENTS.md index 8fd617b..a41ed83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,542 +1,75 @@ # AGENTS.md -## 1. Overview +## Overview -agentskill is a single-repo Python CLI published from the `agsk` package metadata and exposed as the `agentskill` console command. It analyzes one or more repositories, emits structured analyzer output, and also supports direct `AGENTS.md` generation and in-place update flows. The codebase is organized around packaged runtime modules under `agentskill/`, thin direct wrappers under `scripts/`, reference specs and fixture repositories at the repo root, and a separate `tests/` tree that exercises analyzer internals, generation/update flows, and CLI entrypoints. +agentskill is a Rust CLI distributed as `agentskill` and `agsk` binaries. It +analyzes one or more repositories, emits structured analyzer output, and +supports deterministic `AGENTS.md` generation and in-place updates. -## 2. Repository Structure +## Repository Structure ```text -agentskill/ - agentskill/ - main.py # packaged CLI entry point; argument parsing and dispatch only - commands/ # analyzer implementations - lib/ # orchestration, output, reference, generation, and update helpers - common/ # shared low-level helpers and registries - pyproject.toml # packaging, pytest, ruff, coverage config - README.md # user-facing overview and command reference - SYSTEM.md # synthesis spec for generated AGENTS.md files - SKILL.md # operational workflow for the skill - AGENTS.md # conventions for this repo - LICENSE # MIT license text - references/ - GOTCHAS.md # extraction and synthesis failure modes - examples/ - python/ # fixture repository used by analyzer tests - javascript/ # fixture repository for JS/TS detection paths - mixed/ # multi-language fixture repository - ... # additional per-language example repos - scripts/ - analyze.py # thin direct-execution wrapper to packaged CLI - scan.py # thin direct-execution wrapper - measure.py # thin direct-execution wrapper - config.py # thin direct-execution wrapper - git.py # thin direct-execution wrapper - graph.py # thin direct-execution wrapper - symbols.py # thin direct-execution wrapper - tests.py # thin direct-execution wrapper - generate.py # thin direct-execution wrapper to packaged CLI - update.py # thin direct-execution wrapper to packaged CLI - tests/ # pytest suite; separate tree, not colocated - conftest.py # sys.path test bootstrap - test_support.py # shared repo/setup helpers for tests +agentskill-core/ Shared errors, types, filesystem, language registry +agentskill-analyzers/ Seven analyzers and aggregate execution +agentskill-generation/ References, rendering, layouts, and update merging +agentskill/ Clap CLI and binary targets +agentskill-skill/ Skill instructions, references, and target fixtures +agentskill-scripts/ Release-note and archive verification helpers +agentskill-docs/ CLI and architecture reference +agentskill-assets/ Repository artwork +agentskill-tests/ Contract fixtures for compatibility checks ``` -- New analyzer logic goes in `agentskill/commands/`, not in entrypoint wrappers. -- Shared CLI plumbing, generation, reference adaptation, and update flows belong in `agentskill/lib/`; low-level reusable helpers belong in `agentskill/common/`. -- Files under `scripts/*.py` stay as thin wrappers around packaged command entrypoints such as `agentskill.commands..main` or `agentskill.main`. -- New tests go in `tests/` as `test_.py`; this repo does not colocate tests beside source files. -- New fixture repos or language-shape examples belong in `examples/`, not mixed into `references/`. -- Specs and extraction notes belong in `README.md`, `SYSTEM.md`, `SKILL.md`, and `references/`, not inside runtime modules. -- Keep the repo root small: metadata, docs/spec files, and no business logic outside `agentskill/`. +Keep implementation logic in its owning crate. Keep `agentskill/src/main.rs` +thin and route behavior through the library crates. Target-language fixtures +may use Python or any other supported language; the agentskill implementation +must remain Rust-only. -## 5. Commands and Workflows +## Commands And Workflows ```bash -# Install editable package with dev tooling -python -m pip install -e '.[dev]' - -# Optional local hooks -pre-commit install - -# Run all analyzers -agentskill analyze --pretty - -# Generate or update markdown -agentskill generate -agentskill generate --out AGENTS.md -agentskill generate --interactive -agentskill generate --profile comprehensive -agentskill generate --layout split --out AGENTS.md -agentskill generate --layout multifile --out AGENTS.md -agentskill update -agentskill update --section testing -agentskill update --force - -# Run individual analyzers through the installed CLI -agentskill scan --pretty -agentskill measure --lang python --pretty -agentskill config --pretty -agentskill git --pretty -agentskill graph --pretty -agentskill symbols --pretty -agentskill tests --pretty - -# Direct wrapper execution -python scripts/analyze.py --pretty -python scripts/scan.py --pretty -python scripts/generate.py -python scripts/update.py - -# Local checks -ruff format . -ruff check . -mypy -pytest -``` - -- `python -m pip install -e '.[dev]'` is the documented development install path; use it instead of reconstructing the dev dependency list manually. -- `agentskill analyze --pretty` is the canonical aggregate analyzer workflow. -- `agentskill generate ` is the fresh-draft path; `agentskill update ` is the in-place merge/preservation path. -- `agentskill --pretty` is the main single-analyzer interface; `python scripts/.py --pretty` remains supported as a thin direct wrapper. -- Use `ruff format .`, `ruff check .`, `mypy`, and `pytest` as the canonical local verification stack. - -## 6. Code Formatting - -### Python - -Configured tooling: Ruff is configured in `pyproject.toml` for linting, and the repo documents `ruff format .` as the formatting command. No formatter-specific overrides are declared, so follow the observed formatting directly. - -**Indentation:** 4 spaces. - -```python -def _single_script_cmd(command_name: str, args: argparse.Namespace) -> int: - metadata = COMMANDS[command_name] - extra_kwargs = {} - - if metadata["supports_lang"]: - extra_kwargs["lang_filter"] = getattr(args, "lang", None) -``` - -**Line length:** keep ordinary code in the mid-70s or below; measured p95 is 76. Long regex literals and long docstring summary lines still appear. - -```python -CONVENTIONAL_PREFIX_RE = re.compile(r"^([a-z][a-z0-9_-]*)(\([^)]+\))?(!)?\s*:\s*(.+)$") -``` - -**Blank lines — top-level:** 2 blank lines between top-level functions and constants-to-functions transitions. - -```python -from agentskill.lib.output import run_and_output, write_output -from agentskill.lib.runner import COMMANDS, run_many - - -def cmd_analyze(args: argparse.Namespace) -> int: - result = run_many(args.repos, getattr(args, "lang", None)) -``` - -**Blank lines — methods:** not applicable in the dominant code path; classes are effectively absent in source. - -**Blank lines — class open:** not applicable in source for the same reason. - -**Blank lines — after imports:** usually 1 blank line before module constants; 2 blank lines before the first function when a file goes straight from imports into functions. - -```python -from agentskill.lib.output import run_and_output - -GIT_TIMEOUT = 30 -GIT_HASH_LENGTH = 40 -``` - -```python -from test_support import create_sample_repo - -from agentskill.main import main - - -def test_cli_scan_outputs_json(tmp_path, capsys): -``` - -**Blank lines — end of file:** every file ends with exactly 1 trailing newline. - -**Trailing whitespace:** stripped. - -**Brace / bracket placement:** opening delimiters stay on the same line; multiline calls and literals use hanging indentation with the closing delimiter on its own line. - -```python -return run_and_output( - metadata["fn"], - repo=args.repo, - pretty=args.pretty, - out=getattr(args, "out", None), - script_name=command_name, - extra_kwargs=extra_kwargs, -) -``` - -**Quote style:** double quotes everywhere in normal Python code and docstrings. - -```python -if not repo.exists(): - return {"error": f"path does not exist: {repo_path}", "script": "git"} -``` - -**Spacing — operators:** spaces around assignment and binary operators. - -```python -avg_parents = sum(parent_counts) / len(parent_counts) -bucket = prefix if prefix else "unprefixed" -``` - -**Spacing — inside brackets:** no inner padding. - -```python -if COMMANDS[command_name]["supports_lang"]: -``` - -**Spacing — after commas:** always a single space. - -```python -return None, None, False -``` - -**Spacing — colons:** no space before `:`, one space after `:` in dict literals, none in type annotations. - -```python -prefixes[k] = { - "count": v["count"], - "pct": round(v["count"] / total * 100, 1), - "example": v["example"], -} -``` - -```python -def run_many(repos: list[str], lang_filter: str | None = None) -> dict: -``` - -**Spacing — decorators:** decorators are flush with the function they decorate, with no blank line between decorator and `def`. - -```python -@pytest.fixture -def repo_fixture(tmp_path): - return create_sample_repo(tmp_path) -``` - -**Import block formatting:** one import per line; groups are separated by a blank line. In source files, stdlib imports come first and local package imports follow. Tests usually import local helpers before packaged runtime modules. - -```python -import json - -from test_support import create_sample_repo - -from agentskill.main import main -``` - -**Trailing commas:** used in multiline calls, dicts, lists, and imports. - -```python -exit_code = main( - ["analyze", str(repo_one), str(repo_two), "--out", str(out_file)] -) -``` - -**Line continuation:** implicit via open brackets; no backslash continuations. - -**Semicolons:** absent. - -## 7. Naming Conventions - -### Python - -**Functions and methods:** public entrypoints use plain snake_case names like `analyze`, `measure`, `build_graph`, `extract_symbols`; internal helpers use `_snake_case`. - -```python -def analyze(repo_path: str) -> dict: -def build_graph(repo_path: str, lang_filter: str | None = None) -> dict: -def _detect_merge_strategy(cwd: str) -> tuple[str, str]: -``` - -**CLI command helpers:** root CLI helper names read as verbs or command phrases. - -```python -def cmd_analyze(args: argparse.Namespace) -> int: -def _single_script_cmd(command_name: str, args: argparse.Namespace) -> int: -``` - -**Constants:** module constants use `SCREAMING_SNAKE_CASE`. - -```python -GIT_TIMEOUT = 30 -PRETTIER_CONFIG_FILES = [ -MAKEFILE_NAMES = ["Makefile", "makefile", "GNUmakefile"] -``` - -**Private members:** internal helpers overwhelmingly use a single leading underscore; there is no meaningful double-underscore pattern. - -```python -def _parse_toml_value(s: str): -def _measure_line_lengths(all_lengths: list[int]) -> dict: -def _command_kwargs(command_name: str, lang_filter: str | None) -> dict: -``` - -**File names:** source and helper files use lowercase snake_case; test files use `test_.py`; package markers use `__init__.py`. - -```text -agentskill/lib/output.py -agentskill/common/constants.py -tests/test_measure.py -tests/test_support.py -``` - -**Directory names:** lowercase simple nouns: `agentskill`, `scripts`, `tests`, `references`, `examples`. - -**Test function names:** `test_` with long descriptive tails is the dominant pattern. - -```python -def test_cli_writes_out_file_and_multi_repo_results(tmp_path): -def test_graph_detects_relative_imports_cycles_and_parse_errors(tmp_path): -``` - -**Fixture names:** when fixtures appear, they use snake_case nouns. - -```python -def sample_fixture(): -def repo_fixture(tmp_path): -``` - -## 8. Type Annotations - -### Python - -- Public functions are annotated on parameters and return types. -- Internal helpers are also usually annotated; this repo does not reserve annotations only for public APIs. -- Use built-in generics like `list[str]`, `dict[str, int]`, and union syntax like `str | None` instead of `List`, `Dict`, or `Optional`. -- Container-rich return types are accepted directly in signatures instead of being hidden behind aliases. -- `mypy` is configured in `pyproject.toml`; this repo does not rely on type annotations for linting alone. - -```python -def main(argv: list[str] | None = None) -> int: -def _run(cmd: list[str], cwd: str) -> tuple[int, str]: -def run_many(repos: list[str], lang_filter: str | None = None) -> dict: -``` - -```python -def _analyze_branches(cwd: str) -> tuple[dict[str, int], int, list[str]]: -``` - -## 9. Imports - -### Python - -- Import order is not strict stdlib/third-party/local in the test suite; document and mimic the local file pattern instead of forcing generic ordering. -- In source files, stdlib imports come first, then local package imports separated by one blank line. -- In tests, import the packaged entrypoint from `agentskill.main` unless a compatibility path is being exercised explicitly. -- No wildcard imports. -- No `__future__` imports appear. - -Canonical source import block: - -```python -import re -import subprocess -import sys -from pathlib import Path - -from agentskill.lib.output import run_and_output -``` - -Canonical test import block: - -```python -import json - -from test_support import create_sample_repo - -from agentskill.main import main -``` - -## 10. Error Handling - -### Python - -- Low-level validators and normalization helpers raise `ValueError` with exact message text when the caller provides an invalid path or malformed feedback/config shape. -- User-facing analyzer functions catch those validation failures at the command boundary and return exact two-key payloads shaped as `{"error": ..., "script": ...}`. -- Shared CLI wrappers and aggregate runners catch broad exceptions, log or print diagnostics, and convert failures into a non-zero exit code or the same machine-readable error payload instead of letting tracebacks escape by default. -- Best-effort filesystem helpers degrade quietly for unreadable files by returning `""` or `0`; walker and parser-style helpers also skip individual failures when continuing the scan is more useful than aborting. -- Tests assert exact error strings and exact payload shape, not just exception type. - -```python -def validate_repo(path: str) -> Path: - repo = Path(path).resolve() - - if not repo.exists(): - raise ValueError(f"path does not exist: {path}") - - if not repo.is_dir(): - raise ValueError(f"not a directory: {path}") - - return repo -``` - -```python -def scan(repo_path: str, lang_filter: str | None = None) -> dict: - try: - repo = validate_repo(repo_path) - except ValueError as exc: - return {"error": str(exc), "script": "scan"} -``` - -```python -try: - result = command_fn(repo, **kwargs) -except Exception as exc: - logger.exception("Command %s failed for repo %s", script_name, repo) - result = {"error": str(exc), "script": script_name} -``` - -```python -def read_text(path: Path, max_bytes: int | None = MAX_FILE_BYTES) -> str: - try: - with open(path, "rb") as file_obj: - raw = file_obj.read() if max_bytes is None else file_obj.read(max_bytes) - except Exception: - return "" - - return raw.decode(errors="ignore") -``` - -```python -try: - validate_feedback([]) - raise AssertionError("should have raised ValueError") -except ValueError as exc: - assert str(exc) == "feedback must be an object" -``` - -## 11. Comments and Docstrings - -### Python - -- Modules almost always begin with a triple-double-quoted docstring. -- Function docstrings are short, declarative, and describe return shape or intent; many small helpers omit them. -- Inline comments are rare and only appear when a small detail would otherwise be unclear. -- Comments are not used for narration of obvious code. - -```python -"""Aggregate analyzer execution for the top-level CLI.""" -``` - -```python -def _detect_merge_strategy(cwd: str) -> tuple[str, str]: - """Return (strategy, evidence).""" -``` - -```python -j = last_import # 1-indexed -> 0-indexed -``` - -## 12. Testing - -### Python - -- Framework: `pytest`. -- Tests live in `tests/`, not beside source files. -- Test files are named `test_.py`. -- `tests/conftest.py` adds the repo root to `sys.path`; helper setup code is centralized in `tests/test_support.py`. -- Tests use plain `assert` statements and `tmp_path`, `capsys`, and `monkeypatch` fixtures heavily. -- The suite tests both pure functions and command-line entrypoints. - -```python -def test_cli_scan_outputs_json(tmp_path, capsys): - repo = create_sample_repo(tmp_path) - exit_code = main(["scan", str(repo), "--pretty"]) - - assert exit_code == 0 - - output = json.loads(capsys.readouterr().out) - assert output["summary"]["total_files"] >= 4 -``` - -```python -def test_detect_merge_strategy_paths(monkeypatch): - monkeypatch.setattr(git_command, "_run", lambda cmd, cwd: (1, "")) - assert _detect_merge_strategy("repo") == ("unknown", "insufficient data") -``` - -```python -def write(repo: Path, rel_path: str, content: str) -> Path: - path = repo / rel_path - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content) - return path -``` - -## 13. Git - -- Commit subjects follow conventional-commit-style prefixes. Dominant prefixes in current history are `feat:`, `refactor:`, `docs:`, `release:`, `fix:`, `chore:`, and smaller amounts of `ci:`, `test:`, `style:`, `deps:`, and `build:`. -- Branch names use a slash-separated prefix pattern when they are not trunk branches. -- The current analyzer output detects merge commits rather than a pure rebase-only history, so do not write process notes that assume a strictly linear workflow. -- Commit bodies exist, but not on every commit. - -Examples from current history: - -```text -feat: release version 1.0.0 with updated build workflow and CLI command name -refactor: update project structure moving to an idiomatic one -docs: add comprehensive API and CLI documentation with reference examples -fix: update tag validation in release workflow to use regex for version extraction -``` - -Branch example: - -```text -docs/changelog-0.2.0 -``` - -## 14. Dependencies and Tooling - -- Packaging uses `setuptools.build_meta` with `setuptools>=68` in `build-system.requires`. -- The published console script is `agentskill = "agentskill.main:main"`. -- The distribution package name is `agsk`. -- Runtime requirement is Python `>=3.10`. -- Runtime dependencies include `tomli` and `PyYAML` for Python `<3.11`. -- Dev dependencies include `mypy`, `pre-commit`, `pytest`, `pytest-cov`, `ruff`, `tomli`, `PyYAML`, and `types-PyYAML`. -- Ruff targets `py39`, excludes cache and virtualenv directories, and lint rules are configured in `pyproject.toml` with `select = ["B", "C4", "E4", "E7", "E9", "F", "I", "N", "SIM", "UP", "W"]` and `ignore = ["E402"]`. -- `mypy` is configured in `pyproject.toml` for `agentskill`, `scripts`, and `tests` with `check_untyped_defs = true`, `warn_unused_ignores = true`, `warn_redundant_casts = true`, `warn_unreachable = true`, and `show_error_codes = true`. -- Coverage omits `tests/*`. -- License is MIT. - -```toml -[build-system] -requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" - -[project] -name = "agsk" -version = "1.0.0" -requires-python = ">=3.10" - -[project.scripts] -agentskill = "agentskill.main:main" -``` - -```toml -[tool.ruff] -target-version = "py39" - -[tool.ruff.lint] -select = ["B", "C4", "E4", "E7", "E9", "F", "I", "N", "SIM", "UP", "W"] -ignore = ["E402"] -``` - -## 15. Red Lines - -- Do not put analyzer implementation logic into `agentskill/main.py`; keep it as dispatch and orchestration only. -- Do not add colocated tests under `scripts/`; tests belong under `tests/`. -- Do not introduce `Optional[...]`, `List[...]`, or `Dict[...]` annotation style; the repo uses built-in generics and `| None`. -- Do not switch quote style to single quotes in ordinary Python code. -- Do not start using wildcard imports or `__future__` imports without a repo-wide reason. -- Do not rely on exceptions escaping CLI/output wrappers when the existing pattern returns `{"error": ..., "script": ...}` payloads. -- Do not fold example fixture repos into `references/` or analyzer code; keep repo-shaped fixtures in `examples/`. -- Do not bypass `agentskill/lib/` by adding generation or update orchestration directly to wrappers under `scripts/`. -- Do not assume `python -m agentskill.main` is a supported operator path; the supported surfaces are the installed `agentskill` console script, direct wrapper scripts, and direct `main([...])` invocation in tests. +cargo fmt --all +cargo clippy --workspace --all-targets -- -D warnings +cargo check --workspace --locked +cargo test --workspace --locked +``` + +Run the CLI locally with `cargo run --bin agentskill -- ...` or +`cargo run --bin agsk -- ...`. Release artifacts are built and +published by GitHub Actions from numeric `X.Y.Z` and `X.Y.Z-rc.N` tags. + +## Rust Conventions + +- Use Rust 2024 edition and preserve the MSRV of 1.89. +- Run rustfmt; do not hand-format around it. +- Keep public APIs documented when they cross crate boundaries. +- Prefer typed domain structs at crate boundaries and `serde_json::Value` only + for the intentionally JSON-shaped analyzer contract. +- Return tolerant per-file or per-analyzer errors where a repository scan can + continue; reserve process failure for invalid CLI arguments or unusable paths. +- Keep output deterministic: stable section ordering, sorted file paths, and + reproducible JSON values. + +## Testing + +Tests live in each crate's `tests/` directory or in source modules for focused +unit behavior. Cover command flags, exact analyzer keys and error payloads, +language detection, references, generation profiles/layouts, document merging, +both binary names, and release helper scripts. + +## Release Rules + +`VERSION` is the stable base version and must match the workspace version. +Final release notes are extracted from the matching `CHANGELOG.md` section. +RC tags publish prereleases with generated candidate notes. Archives must +contain `agentskill`, `agsk`, and `LICENSE`, and the release must include +`SHA256SUMS`. + +## Red Lines + +- Do not reintroduce Python runtime code, package setup, or Python CI workflows. +- Do not remove supported target languages or their example fixtures. +- Do not place analyzer or generation logic in the CLI entrypoint. +- Do not change stable JSON/markdown behavior without updating contract tests, + documentation, and the changelog. +- Do not publish an artifact without locked verification and archive checks. diff --git a/CHANGELOG.md b/CHANGELOG.md index ec9148d..891a3b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added a concise root `Makefile` for local verification and release checks. + +## [2.0.0] - 2026-08-27 + +### Added + +- Replaced the Python runtime with a Rust workspace containing shared core, + analyzer, generation, and CLI crates. +- Added `agentskill` and `agsk` release binaries for Linux, macOS, and Windows. +- Added GitHub Release packaging with platform archives and SHA256 checksums. + +### Changed + +- Preserved the analyzer command surface, supported-language matrix, JSON + output shape, and AGENTS.md generation/update workflows. +- Changed installation documentation from PyPI/pip to GitHub Releases and + Cargo source installation. + ## [1.4.0] - 2026-05-06 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3424a77..08a039a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,58 +1,45 @@ # Contributing -When contributing to this repository, please first discuss the change you want -to make through an issue, discussion, or pull request draft where appropriate. +## Development Setup -Before contributing, read and follow: - -- [Code of Conduct](./CODE_OF_CONDUCT.md) -- [README.md](./README.md) -- [AGENTS.md](./AGENTS.md) - -## What To Contribute - -Useful contribution areas include: - -- Analyzer accuracy improvements. -- Richer static `AGENTS.md` generation. -- Language support expansion. -- CLI and output contract improvements. -- Tests, fixtures, and regression coverage. -- Documentation and skill workflow clarity. - -## Development Workflow - -Set up the local environment: +Install Rust through [rustup](https://rustup.rs/), then verify the workspace: ```bash -python -m pip install -e '.[dev]' -pre-commit install +make verify ``` -Run the standard checks before opening a pull request: +The minimum supported Rust version is 1.89. Keep `Cargo.lock` updated when +dependencies change. -```bash -ruff format . -ruff check . -mypy -pytest -``` +## Architecture + +- `agentskill-core` owns shared domain models, repository traversal, language + detection, error payloads, and markdown document operations. +- `agentskill-analyzers` owns the seven analyzer families and their stable JSON + output contracts. +- `agentskill-generation` owns deterministic markdown generation, references, + profiles, layouts, interactive notes, and update merges. +- `agentskill` owns Clap parsing and the `agentskill`/`agsk` binaries only. -## Pull Requests +Keep implementation logic in the appropriate crate. Do not reintroduce runtime +Python or Python package-manager tooling. Python fixtures under +`agentskill-skill/examples/` are retained because Python is a supported target +language for analysis. -- Keep changes focused and reviewable. -- Add or update tests when behavior changes. -- Update docs when the CLI, skill workflow, or generated output semantics change. -- Preserve the packaged/runtime split described in `README.md` and `AGENTS.md`. -- Prefer deterministic behavior and contract-stable output when changing generation code. +## Tests And Contracts -## Issues +Add Rust unit tests beside the owning crate or integration tests under that +crate's `tests/` directory. Preserve command names, flags, output keys, error +payloads, generated section order, and update behavior unless a deliberate v2 +contract change is documented. -Use the repository issue templates for: +## Documentation And Releases -- Bug reports. -- Feature requests. -- Documentation gaps. +Update `README.md`, `agentskill-skill/SKILL.md`, `agentskill-skill/SYSTEM.md`, +and `agentskill-docs/` when public CLI behavior changes. Add user-visible +changes to `CHANGELOG.md`. Stable +release tags must match `VERSION` and have a matching changelog heading; +`X.Y.Z-rc.N` tags publish prereleases automatically. -Include reproduction steps, expected behavior, and actual behavior whenever -possible. +Use `make build`, `make coverage`, `make security`, or `make workflows` for +individual checks. Run `make fmt` to apply Rust formatting. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e5c818d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,587 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "agentskill" +version = "2.0.0" +dependencies = [ + "agentskill-analyzers", + "agentskill-core", + "agentskill-generation", + "clap", + "serde_json", + "tempfile", +] + +[[package]] +name = "agentskill-analyzers" +version = "2.0.0" +dependencies = [ + "agentskill-core", + "rayon", + "regex", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "toml", +] + +[[package]] +name = "agentskill-core" +version = "2.0.0" +dependencies = [ + "regex", + "serde", + "serde_json", + "tempfile", + "thiserror", + "toml", +] + +[[package]] +name = "agentskill-generation" +version = "2.0.0" +dependencies = [ + "agentskill-analyzers", + "agentskill-core", + "serde_json", + "tempfile", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3670afd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[workspace] +members = [ + "agentskill-core", + "agentskill-analyzers", + "agentskill-generation", + "agentskill", +] +resolver = "3" + +[workspace.package] +version = "2.0.0" +edition = "2024" +rust-version = "1.89" +license = "MIT" +authors = ["airscripts"] +repository = "https://github.com/airscripts/agentskill" +homepage = "https://github.com/airscripts/agentskill" +documentation = "https://docs.rs/agentskill" + +[workspace.dependencies] +agentskill-core = { path = "agentskill-core", version = "2.0.0" } +agentskill-analyzers = { path = "agentskill-analyzers", version = "2.0.0" } +agentskill-generation = { path = "agentskill-generation", version = "2.0.0" } +clap = { version = "4.6", features = ["derive"] } +regex = "1.13" +rayon = "1.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" +tempfile = "3.27" +thiserror = "2.0" +toml = "1.0" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f0dbbbc --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +CARGO ?= cargo +ACTIONLINT ?= actionlint +SHELLCHECK ?= shellcheck + +.PHONY: all build check coverage fmt lint security test verify workflows + +all: verify + +build: + $(CARGO) build --release --workspace --locked + +check: + $(CARGO) check --workspace --locked + +coverage: + $(CARGO) llvm-cov --workspace --locked --summary-only --fail-under-lines 80 + +fmt: + $(CARGO) fmt --all + +lint: + $(CARGO) clippy --workspace --all-targets --locked -- -D warnings + +security: + $(CARGO) deny check + +test: + $(CARGO) test --workspace --locked + +verify: lint check test workflows + +workflows: + $(ACTIONLINT) -color + $(SHELLCHECK) --shell=bash agentskill-scripts/*.sh diff --git a/PLANNER.md b/PLANNER.md deleted file mode 100644 index c96da49..0000000 --- a/PLANNER.md +++ /dev/null @@ -1,80 +0,0 @@ -# Planner - -You are a release planner for the `agentskill` GitHub repository. - -Your job is to turn the repository roadmap into high-quality, implementation-ready release plans and PR plans with the same style and rigor as the previous planning chat. - -## Core Responsibilities - -- Read the live `ROADMAP.md` from the GitHub repo before planning a release. -- Ground plans in the actual repository structure when useful by checking relevant files in the repo. -- Break each release into a small set of reviewable PRs with clear sequencing and rationale. -- When asked for a specific PR, produce an agent-ready implementation brief. -- When asked, generate a downloadable `.md` artifact containing the PR brief. - -## Repository - -- GitHub Repo: `airscripts/agentskill` - -## Planning Standards - -- Be concrete, implementation-oriented, and scoped. -- Prefer realistic PR boundaries over idealized architecture. -- Keep each PR focused enough to be reviewed independently. -- Preserve backward compatibility unless the roadmap clearly implies otherwise. -- Call out what is in scope vs out of scope. -- Include likely files to change. -- Include acceptance criteria, testing requirements, recommended implementation choices, branch name, and suggested commit breakdown. -- Avoid unnecessary clarifying questions; make the best grounded plan from the roadmap and repo state. -- Do not invent repository details. Check the repo when needed. -- Be explicit about uncertainty when something is not yet present in the codebase. - -## Required Workflow - -1. Fetch `ROADMAP.md` from GitHub first. -2. If the request is for a release-level plan, summarize the release theme and break it into PRs. -3. If the request is for a PR-level plan, inspect relevant repository files before detailing the PR. -4. Keep consistency with earlier plans: - - release overview first - - then PR-by-PR detailed briefs - - then downloadable markdown files on request - -## Output Style - -- Clear, direct, and structured. -- Use headings like: - - PR title - - Goal - - Why this PR exists - - In scope - - Out of scope - - Expected files to change - - Design requirements - - Implementation details - - Suggested concrete task list for the agent - - Testing requirements - - Acceptance criteria - - Recommended implementation choices - - Non-goals / guardrails - - Suggested PR description - - Branch name - - Commit breakdown -- Keep wording agent-friendly and handoff-ready. - -## GitHub/Tooling Behavior - -- Use `api_tool.call_tool` directly for GitHub operations. -- Prefer fetching `ROADMAP.md` and relevant repo files over guessing. -- When creating a markdown handoff file, produce a downloadable `.md` artifact. - -## Constraints - -- Do not mix multiple roadmap PRs into one brief unless explicitly asked. -- Do not drift into implementation unless asked. -- Do not propose broad rewrites when a smaller PR sequence is better. -- Do not use vague advice like “improve code quality”; specify exactly what should change and how it should be tested. - -## Quality Bar - -- Plans should be detailed enough that an autonomous coding agent can implement them with minimal follow-up. -- The result should feel like a senior engineer’s release planning document, not a brainstorm. diff --git a/README.md b/README.md index 1059ee1..4a88cf8 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,11 @@ [![Release](https://github.com/airscripts/agentskill/actions/workflows/release.yml/badge.svg)](https://github.com/airscripts/agentskill/actions/workflows/release.yml) ![ClawHub](https://skill-history.com/badge/airscripts/agentskill.svg) -Analyze a code repository and synthesize an `AGENTS.md` that lets any agent produce code indistinguishable from the existing codebase. +Analyze a code repository and synthesize an `AGENTS.md` that lets any agent +produce code consistent with the existing codebase.

- agentskill + agentskill

--- @@ -18,10 +19,16 @@ Analyze a code repository and synthesize an `AGENTS.md` that lets any agent prod - [How It Works](#how-it-works) - [Supported Languages](#supported-languages) - [Generation Modes](#generation-modes) -- [Install](#install) +- [Installation](#installation) - [Development Checks](#development-checks) - [Usage](#usage) -- [Repository Structure](#repository-structure) +- [Choosing a Command](#choosing-a-command) +- [References](#references) +- [Interactive Generation](#interactive-generation) +- [Update Workflow](#update-workflow) +- [Profiles and Layouts](#profiles-and-layouts) +- [Repo-Local Feedback](#repo-local-feedback) +- [Repository Layout](#repository-layout) - [Where Code Goes](#where-code-goes) - [Developer Workflow](#developer-workflow) - [File Ecosystem](#file-ecosystem) @@ -29,6 +36,7 @@ Analyze a code repository and synthesize an `AGENTS.md` that lets any agent prod - [API Reference](#api-reference) - [Contributing](#contributing) - [Security](#security) +- [Releases](#releases) - [Statistics](#statistics) - [Support](#support) - [License](#license) @@ -37,41 +45,41 @@ Analyze a code repository and synthesize an `AGENTS.md` that lets any agent prod ## What It Does -agentskill is not a linter and not a style guide generator. It is a forensic extraction tool. It walks a repository, measures every line, reads every config file, and inspects the commit log — then synthesizes a precise behavioral spec for a code-generating agent. +agentskill is not a linter or a generic style-guide generator. It is a +forensic extraction tool. It walks a repository, measures source conventions, +reads formatter and linter configuration, inspects Git history, and analyzes +imports, symbols, and tests. It then emits structured evidence or a +deterministic `AGENTS.md` document. -The output is not advice. It is mimicry instructions. - ---- +The output is not generic advice. It is repository-specific guidance for an +agent working in an existing codebase. ## How It Works -Seven analyzers run in parallel. Each extracts one class of signal that an LLM cannot derive reliably from reading source files alone: +Seven analyzers run independently and their results are combined in a stable +JSON shape: -| Analyzer | What it measures | -| --------- | ------------------------------------------------------------------- | -| `scan` | Directory tree, file inventory, suggested read order | -| `measure` | Exact indentation, line length percentiles, blank line distributions | -| `config` | Formatter, linter, and type-checker detection with config excerpts | -| `git` | Commit prefixes, branch naming, merge strategy, signing | -| `graph` | Internal import graph, circular dependencies, most-depended modules | -| `symbols` | Symbol name extraction, naming pattern clustering, affix detection | -| `tests` | Test-to-source mapping, framework detection, fixture extraction | +| Analyzer | What it measures | +| --- | --- | +| `scan` | Directory tree, file inventory, languages, and suggested read order | +| `measure` | Indentation, line-length percentiles, blank lines, and whitespace | +| `config` | Formatter, linter, type-checker, editor, and project configuration | +| `git` | Commit subjects, prefixes, branches, merge signals, and history | +| `graph` | Internal imports, cycles, dependency concentration, and boundaries | +| `symbols` | Functions, types, constants, naming patterns, and affixes | +| `tests` | Test frameworks, mappings, fixtures, and test commands | -Analyzer output feeds directly into `AGENTS.md` synthesis. The synthesis step follows the behavioral spec in [`SYSTEM.md`](./SYSTEM.md). +The generation crate turns this evidence into ordered markdown sections. The +generation contract is documented in [`agentskill-skill/SYSTEM.md`](./agentskill-skill/SYSTEM.md). +The seven analyzers are implemented in Rust and run in parallel where the +workspace can safely do so. -> Check our latest technical article for a deeper dive: -> [Turning Repository Knowledge Into Usable Agent Context](https://dev.to/airscript/turning-repository-knowledge-into-usable-agent-context-4pe4). - ---- +> Read the technical background: [Turning Repository Knowledge Into Usable +> Agent Context](https://dev.to/airscript/turning-repository-knowledge-into-usable-agent-context-4pe4). ## Supported Languages -agentskill already ships analyzer coverage and repository examples across a -wide set of languages. This matters because the tool is meant to extract -project-specific conventions from real repositories, not only from Python-only -layouts. - -Current supported language set: +The analyzer matrix and the example fixtures cover: - Python - TypeScript @@ -89,310 +97,234 @@ Current supported language set: - Objective-C - Shell / Bash -The repository also includes fixture/example projects for these languages under -[`examples/`](./examples/), which act as both regression coverage and reference -shapes for multi-language analysis. - ---- +These are target languages. agentskill itself is implemented and shipped +entirely in Rust. The fixtures under +[`agentskill-skill/examples/`](./agentskill-skill/examples/) provide compact +single-language, mixed-language, and monorepo shapes for regression coverage. ## Generation Modes -agentskill supports two distinct generation modes: +agentskill supports deterministic CLI generation and AI-assisted skill +generation. ### Static Generation (CLI) -Use the CLI when you want deterministic output produced by the packaged -runtime without an LLM in the loop. - -- `agentskill analyze --pretty` for combined machine-readable analysis -- `agentskill generate ` for a fresh `AGENTS.md` draft -- `agentskill generate --profile comprehensive` for a richer draft with representative snippets and expanded detail -- `agentskill generate --layout split --out AGENTS.md` for separate concise primary plus comprehensive companion -- `agentskill generate --layout multifile --out AGENTS.md` for per-section markdown files with a root index -- `agentskill update ` for deterministic regeneration of an existing `AGENTS.md` +Use the CLI when the packaged Rust runtime should produce reproducible output: -This is the right mode for CI workflows, automation, and operator-driven -usage where the packaged runtime produces the final document. +- `agentskill analyze --pretty` emits machine-readable evidence. +- `agentskill generate ` creates a fresh document. +- `agentskill generate --profile comprehensive` includes richer detail. +- `agentskill generate --layout split` creates a concise document and a + comprehensive companion. +- `agentskill generate --layout multifile` creates an index and one file + per generated section. +- `agentskill update ` regenerates sections while preserving manual text. ### AI-Assisted Generation (Skill) -This repository is also distributed as a dedicated skill through the repo-root -[`SKILL.md`](./SKILL.md). In this mode, an agent harness installs the skill, -follows the workflow defined in `SKILL.md`, and the **model itself authors** -the final `AGENTS.md` from gathered evidence. +The repository also contains a complete skill package in +[`agentskill-skill/`](./agentskill-skill/). An agent harness can install that +directory as a skill, run the analyzers for evidence, read `SYSTEM.md`, and +author the final `AGENTS.md` itself. This mode supports conversational +refinement and context-aware section depth. -In skill mode: +The skill workflow uses analyzer commands as evidence gathering; it does not +need to invoke the static `generate` command. Use the CLI for deterministic +runtime output and the skill when an agent should synthesize and refine the +document interactively. -- The agent uses analyzer commands (`analyze`, `scan`, `measure`, `config`, - `git`, `graph`, `symbols`, `tests`) to extract repository facts. -- The agent asks the user which **profile** (concise or comprehensive) and - **layout** (single, split, or multifile) they want before generating. -- The **model synthesizes the final document itself** — it does not call - `agentskill generate` to produce the output. -- This allows richer, more adaptive generation than CLI static output can - provide: interactive feedback, context-aware section depth, and conversational - refinement. +## Installation -In short: +Download the archive for your platform from +[GitHub Releases](https://github.com/airscripts/agentskill/releases), extract +it, and put either `agentskill` or `agsk` on your `PATH`. Verify downloads with +the release's `SHA256SUMS` file. -- use the CLI for **deterministic static generation**, -- use the skill for **AI-authored generation** with richer adaptation. - ---- - -## Install +For a source checkout, install the release binary with Cargo: ```bash -pip install agsk +cargo install --git https://github.com/airscripts/agentskill agentskill ``` -This installs the `agentskill` CLI command. - -Published package is available at: - -- PyPI: -- ClawHub: - -For local development: - -```bash -python -m pip install -e '.[dev]' -``` - -To enable the commit-time checks after installing the dev environment: - -```bash -pre-commit install -``` +Both binary names are built from the workspace. `agsk` is an equivalent short +name for `agentskill`. ### For Agents -This repository is also distributed as a standard skill with a repo-root -`SKILL.md`. Harnesses that support skill installation from a filesystem path, -git repository, or marketplace entry should install it as a normal skill and -use `SKILL.md` as the entrypoint document. - -Generic install guidance for skill-aware harnesses: - -- If the harness installs skills from a local path, point it at the repository - root so it can read `SKILL.md`, `SYSTEM.md`, `references/`, and `examples/`. -- If the harness installs skills from a git repository, use this repository URL - and keep the repo-root `SKILL.md` as the skill manifest. -- If the harness installs skills from a marketplace, use the ClawHub entry: - . -- If the harness only needs the CLI and not the skill manifest, install the - PyPI package instead: . - -Expected skill layout: +Install the repository root as a skill when your harness supports filesystem or +Git skill installation. The relevant package layout is: ```text -SKILL.md # skill entrypoint and workflow -SYSTEM.md # generation/synthesis behavioral spec -references/ # gotchas and supporting guidance -examples/ # fixture repos and reference shapes +agentskill-skill/ + SKILL.md # skill entrypoint and workflow + SYSTEM.md # generated-document contract + references/ # extraction and synthesis guidance + examples/ # target-language fixtures and reference shapes ``` -After a harness installs the skill, the analyzer commands remain available -for evidence gathering: +If the harness only needs the analyzer runtime, install the binaries and use +the commands below. The skill package and the Rust CLI are intentionally +separate: the former gives an agent a synthesis workflow, while the latter +provides deterministic evidence and document operations. -```bash -# Evidence gathering (used in skill mode) -agentskill analyze --pretty -agentskill scan --pretty -agentskill measure --lang python --pretty -agentskill config --pretty -agentskill git --pretty -agentskill graph --pretty -agentskill symbols --pretty -agentskill tests --pretty - -# Static generation (CLI/operator mode only, not for skill workflows) -agentskill generate -agentskill update -``` - -In skill mode, the model authors the final `AGENTS.md` itself. The -`generate` and `update` commands are for direct CLI use only. - ---- +The skill is also listed on [ClawHub](https://clawhub.ai/airscripts/agentskill) +for harnesses that install skills from a marketplace. ## Development Checks -Run the canonical local checks: - -```bash -ruff format . -ruff check . -mypy -pytest -``` - -To verify formatting without rewriting files: +Install Rust through [rustup](https://rustup.rs/). The minimum supported Rust +version is 1.89. The canonical verification command is: ```bash -ruff format --check . -ruff check . -mypy -pytest +make verify ``` -`mypy` is the repo's configured type-check command. Its configuration in -`pyproject.toml` covers `agentskill/`, `scripts/`, and `tests/`. - -Optional commit-time hooks are available if you want them locally: +This runs locked linting and compilation, the complete workspace test suite, +and workflow/script validation. Individual targets are +available when iterating: ```bash -pre-commit install -pre-commit run --all-files +make build # release binaries +make check # cargo check --workspace --locked +make fmt # cargo fmt --all +make lint # clippy with -D warnings +make test # cargo test --workspace --locked +make coverage # llvm-cov with the 80% line threshold +make security # cargo-deny dependency policy checks +make workflows # actionlint and shellcheck ``` -The pre-commit setup mirrors the lightweight formatting, lint, and type-check -passes. Full `pytest` runs remain part of normal local verification and CI. - ---- +`Cargo.lock` is committed so local and CI builds use reproducible dependency +resolution. Optional staged-file checks are configured through `lefthook.yml` +and `agentskill-scripts/pre-commit.sh`. ## Usage +Global `--pretty` and `--out FILE` options apply to analyzer commands. `generate` +and `update` produce markdown and therefore reject `--pretty`. + ```bash -# Canonical installed CLI +# Aggregate or focused evidence agentskill analyze --pretty +agentskill analyze --pretty agentskill scan --pretty -agentskill measure --lang python --pretty +agentskill measure --lang rust --pretty agentskill config --pretty agentskill git --pretty agentskill graph --pretty agentskill symbols --pretty agentskill tests --pretty -# Write output to file -agentskill analyze --out report.json -agentskill analyze --reference ../reference-repo --pretty +# Save analyzer JSON +agentskill --out report.json analyze -# Generate AGENTS.md markdown directly +# Generate a fresh document agentskill generate agentskill generate --out AGENTS.md -agentskill generate --reference ../ref-a --reference ../ref-b -agentskill generate --interactive -agentskill generate --profile concise agentskill generate --profile comprehensive agentskill generate --layout split agentskill generate --layout multifile -agentskill generate --layout multifile --profile concise --out AGENTS.md -# Update or create AGENTS.md in place +# Update an existing document agentskill update agentskill update --section testing agentskill update --exclude-section git agentskill update --force agentskill update --out updated-AGENTS.md -agentskill update --profile concise - -# Retained wrapper entrypoints for operator/skill workflows -python scripts/analyze.py --pretty -python scripts/scan.py --pretty -python scripts/measure.py --lang python --pretty -python scripts/generate.py -python scripts/update.py ``` -The installed `agentskill` command is the steady-state CLI surface, including -local development after an editable install. The retained `scripts/*.py` -wrappers exist for direct analyzer execution and skill/operator workflows; they -are not the primary runtime surface. +Use `agsk` in place of `agentskill` for every command. Run +`agentskill --help` or `agentskill --help` for the exact current +Clap syntax. + +## Choosing a Command -The published console entrypoint is `agentskill.main:main`. The packaged -runtime under `agentskill/` is the source of truth for subcommand behavior, -output contracts, generation, update flows, and reference handling. +Use `analyze` when you want JSON from all analyzers without writing markdown. +It accepts one or more repositories and is the contract-stable inspection +path. Use an individual analyzer when a focused signal is needed. -### Choosing `analyze`, `generate`, or `update` +Use `generate` for a new document. Single-layout generation prints markdown to +stdout by default and only writes a file when `--out` is supplied. It never +merges with an existing `AGENTS.md`. -Use `analyze` when you want machine-readable JSON from all analyzers and do not -want to touch any markdown files. This is the contract-stable inspection path. +Use `update` when an `AGENTS.md` already exists or when you want deterministic +regeneration with preservation. It writes to `/AGENTS.md` by default, or +to `--out` while still using the repository's existing document as merge input. -Use `generate` when you want a fresh AGENTS draft from current analyzer output. -It prints markdown to stdout by default, never merges with an existing -`AGENTS.md`, and only writes a file when you pass `--out`. +## References -Use `update` when you already have an `AGENTS.md` and want deterministic -regeneration plus preservation of untouched manual content. It writes back to -`/AGENTS.md` by default, or to `--out` while still using the repo-local -`AGENTS.md` as merge input. +`analyze` and `generate` accept repeatable `--reference` flags. A local +reference must be a directory containing a readable, non-empty `AGENTS.md`. +Remote Git URLs (`http://`, `https://`, `ssh://`, or `git@...`) are cloned shallowly and +read the same way. References are explicit inputs, are validated before use, +and duplicate local sources are rejected. -### Reference Workflow +```bash +agentskill analyze --reference ../reference-repo --pretty +agentskill generate \ + --reference ../reference-a \ + --reference https://github.com/example/reference.git +``` -Both `analyze` and `generate` accept repeatable `--reference` flags. References -are explicit inputs, not hidden priors. +Reference provenance is retained in generated metadata, including source and +commit information when available. References do not silently change the +analyzer JSON contract. -- Every local reference must point to a directory with a readable `AGENTS.md`. -- Duplicate references are rejected instead of being silently counted twice. -- `analyze --reference` validates references but does not change the JSON output - shape. -- `generate --reference` preserves reference order in the emitted metadata block - so the provenance is inspectable. +## Interactive Generation -### Interactive Generation +`generate --interactive` is opt-in gap filling. It asks when important signals +are unavailable, such as a canonical test command or Git conventions. Answers +are inserted as explicit notes in the relevant sections; +an answer inferred from a supplied reference avoids an unnecessary prompt. -`generate --interactive` is opt-in guided gap filling. It asks a small number of -targeted questions only when important signals are missing or ambiguous, then -injects those answers into the generated markdown as explicit interactive notes. +```bash +agentskill generate --interactive +``` -References can reduce prompt count when they clearly provide the missing -convention. Conflicting references do not get auto-resolved; the command asks -instead of guessing. +Review generated notes against the repository when evidence from multiple +sources differs. -### Update Workflow +## Update Workflow -`agentskill update ` analyzes the repository, regenerates AGENTS sections, -merges them with any existing `AGENTS.md`, and writes the result back to -`/AGENTS.md` by default. +`agentskill update ` analyzes the repository, regenerates generated +sections, merges them with the existing document, and writes the result back. -- Use `--section` to regenerate only named sections. -- Use `--exclude-section` to keep generated sections untouched. +- `--section NAME` regenerates only named sections. +- `--exclude-section NAME` leaves named generated sections untouched. - Missing targeted sections are inserted without rewriting unrelated manual sections. -- Untouched custom sections and preamble text stay in place in normal mode. -- Use `--force` for a clean-slate rebuild that drops preserved/manual sections - and ignores preservation hints from feedback. +- Untouched custom sections and preamble text remain in place in normal mode. +- `--force` performs a clean-slate rebuild and ignores preservation hints. -### Output Profiles and Layouts +`update` currently supports only the default `single` layout. Passing `split` +or `multifile` is rejected clearly. -`generate` and `update` accept `--profile` to control content density. `generate` -also accepts `--layout` to control how the output is packaged across files. +## Profiles and Layouts -#### Profiles (content density) +### Profiles (content density) -Both `generate` and `update` accept `--profile`: +`generate` and `update` accept `--profile`: -- `--profile concise` (default) — operational rules and key facts only; omits - representative code snippets and secondary explanatory bullets. -- `--profile comprehensive` — includes everything from concise plus - representative snippets, annotation counts, expanded explanatory bullets, - and richer provenance from analyzer results. +- `concise` (default) contains operational rules and key facts. +- `comprehensive` adds a verification reminder to each generated section for + workflows that need more guidance while reviewing evidence. -All profiles produce deterministic output from the same analyzer results. The -same section headings and section order are preserved regardless of profile. +Both profiles are deterministic and preserve section headings and order. -#### Layouts (output packaging) +### Layouts (Output Packaging) -`generate` accepts `--layout` to control file packaging: +`generate` accepts `--layout`: -- `--layout single` (default) — writes one complete markdown file. -- `--layout split` — writes two files: a concise primary document and an - `AGENTS.reference.md` companion with comprehensive content. The primary file - links to the companion. Split mode always uses concise for the primary and - comprehensive for the companion regardless of the `--profile` flag; the - `--profile` flag only affects `single` and `multifile` layouts. If `--out` is - omitted, split writes into the target repo using `AGENTS.md` as the primary - path. -- `--layout multifile` — writes a compact root index plus per-section markdown - files in a `.agentskill/` directory beside the primary output. Each section file - includes a backlink to the root. The `--profile` flag controls the density of - content in each section file. If `--out` is omitted, multifile writes into - the target repo using `AGENTS.md` as the root path. Multifile section filenames - follow a stable numbering scheme: +- `single` (default) emits one complete markdown document. +- `split` writes a concise `AGENTS.md` and an `AGENTS.reference.md` companion; + the primary links to the companion. The primary is always concise and the + companion always comprehensive. +- `multifile` writes a compact `AGENTS.md` index and section files in a + `.agentskill/` directory. Section filenames use stable numbering, for + example `01_OVERVIEW.md`, `05_COMMANDS_AND_WORKFLOWS.md`, and + `12_TESTING.md`: ```text - AGENTS.md .agentskill/ 01_OVERVIEW.md 02_REPOSITORY_STRUCTURE.md @@ -409,276 +341,172 @@ same section headings and section order are preserved regardless of profile. 15_RED_LINES.md ``` -#### How profile and layout interact - -| Layout | `--profile` applies to | Default profile | -|-------------|-----------------------------------------|-----------------| -| `single` | Single output file | `concise` | -| `split` | Ignored; primary is concise, companion is comprehensive | N/A | -| `multifile` | Content in each section file | `comprehensive` | +When `--out` is omitted, split and multifile write into the target repository. +For single layout, markdown goes to stdout unless `--out` is supplied. -#### Default output paths +| Layout | Profile behavior | Default profile | +| --- | --- | --- | +| `single` | Controls the one output document | `concise` | +| `split` | Ignored; primary is concise and companion comprehensive | N/A | +| `multifile` | Controls each section file | `concise` | -- `--layout single` without `--out` prints markdown to stdout. -- `--layout split` without `--out` writes into the target repo: `/AGENTS.md` - and `/AGENTS.reference.md`. -- `--layout multifile` without `--out` writes into the target repo: - `/AGENTS.md` and `/.agentskill/`. -- All layouts accept `--out` to write to a custom location. +## Repo-Local Feedback -#### Update constraints - -`update` only supports `--layout single` (the default). Passing -`--layout split` or `--layout multifile` to `update` is explicitly rejected -with a clear error message. This constraint may be lifted in a future release. - -```bash -# Single-file generation (default) -agentskill generate -agentskill generate --profile comprehensive - -# Split generation (writes into repo by default) -agentskill generate --layout split -agentskill generate --layout split --out AGENTS.md - -# Multifile generation (writes into repo by default) -agentskill generate --layout multifile -agentskill generate --layout multifile --out AGENTS.md -agentskill generate --layout multifile --profile concise --out AGENTS.md - -# Update (single layout only) -agentskill update -agentskill update --profile comprehensive -``` - -### Repo-Local Feedback - -Incremental updates can read an optional repo-local sidecar file named -`.agentskill-feedback.json`. This file is explicit, version-controllable, and -affects only the current repository. It is not hidden memory and it is not -global learning. +Incremental updates can read an optional, version-controlled +`.agentskill-feedback.json` beside the repository's `AGENTS.md`: ```json { "sections": { "overview": { - "prepend_notes": [ - "Mention that deployments go through GitHub Actions." - ] + "prepend_notes": ["Deployments go through GitHub Actions."] }, "testing": { - "pinned_facts": [ - "Use pytest as the canonical test runner." - ] + "pinned_facts": ["Use cargo test as the canonical test runner."] } }, - "preserve_sections": [ - "red lines" - ] + "preserve_sections": ["red lines"] } ``` -Supported feedback keys are intentionally narrow by design: - -- `sections..prepend_notes` -- `sections..pinned_facts` -- `preserve_sections` +Supported keys are intentionally narrow: `sections..prepend_notes`, +`sections..pinned_facts`, and `preserve_sections`. In normal update mode, +preserved sections act like an implicit exclusion list. `--force` ignores those +hints. Use the sidecar for durable regeneration guidance; edit `AGENTS.md` +directly for one-off manual text. -In normal update mode, `preserve_sections` acts like an implicit exclusion list. -In `--force` mode, those preservation hints are ignored so the command can -produce a true clean-slate rebuild. +## Repository Layout -Use `.agentskill-feedback.json` when you want durable, repo-local regeneration -guidance that should survive future updates. Edit `AGENTS.md` directly when you -are making one-off manual notes that should remain untouched unless you -explicitly target or force-regenerate that section. - ---- - -## Repository Structure - -``` -README.md # user-facing overview and contributor workflow -AGENTS.md # conventions for this repository itself -SYSTEM.md # synthesis spec for generated AGENTS.md files -SKILL.md # operational workflow used by the skill -pyproject.toml # packaging, CLI entrypoint, tool configuration -LICENSE -docs/ - reference/ # packaged API reference for contributors -agentskill/ - main.py # packaged CLI entry point — subcommand dispatch only - commands/ # analyzer implementations - lib/ # orchestration, output, update, generation helpers - common/ # shared low-level helpers and registries -scripts/ - *.py # thin wrappers that import packaged analyzer entrypoints -tests/ # pytest suite for package code and wrapper behavior -references/ - GOTCHAS.md # extraction and synthesis errors to avoid -examples/ - README.md # language fixture index for analyzer validation - python/ # compact per-language analyzer fixtures - javascript/ - typescript/ - go/ - rust/ - java/ - kotlin/ - csharp/ - c/ - cpp/ - ruby/ - php/ - swift/ - objectivec/ - bash/ - mixed/ - SINGLE_LANGUAGE.md # reference output: single-language repo - MULTI_LANGUAGE.md # reference output: multi-language single repo - MONOREPO.md # reference output: monorepo with multiple services +```text +README.md # user-facing overview and contributor workflow +AGENTS.md # conventions for this repository itself +Cargo.toml # Rust workspace definition +Cargo.lock # reproducible dependency resolution +agentskill-core/ # shared types, filesystem, language registry +agentskill-analyzers/ # seven analyzers and aggregate execution +agentskill-generation/ # rendering, references, layouts, and merging +agentskill/ # Clap CLI and agentskill/agsk binaries +agentskill-skill/ # skill instructions, references, and fixtures +agentskill-scripts/ # release and archive verification helpers +agentskill-docs/ # CLI and architecture references +agentskill-assets/ # repository artwork +agentskill-tests/ # compatibility contract fixtures +.github/ # CI, release workflows, and issue templates ``` ---- - ## Where Code Goes -- Put packaged CLI and runtime code in `agentskill/`. -- Put analyzer implementations in `agentskill/commands/`. -- Put shared orchestration, generation, update, and output helpers in `agentskill/lib/`. -- Put reusable low-level helpers and registries in `agentskill/common/`. -- Keep `scripts/` limited to thin wrappers and operator-facing workflow entrypoints. -- Do not add analyzer or business logic to `scripts/`. -- Add tests in `tests/` as `test_.py`; do not colocate tests under `scripts/`. -- Keep root-level files focused on metadata, docs, and project-wide specs. - -There is no separate steady-state runtime under `scripts/`, and there is no -root `cli.py` compatibility entrypoint to extend. New runtime behavior should -land in the package tree and then be exposed through `agentskill.main` if -it belongs on the public CLI. - ---- +- Put shared domain types, filesystem behavior, errors, and language detection + in `agentskill-core/`. +- Put analyzer implementations and aggregate execution in + `agentskill-analyzers/`. +- Put document rendering, profiles, layouts, references, feedback, and update + merging in `agentskill-generation/`. +- Keep `agentskill/src/main.rs` thin; route CLI behavior through the library + crates and expose both binaries from `agentskill/`. +- Keep `agentskill-scripts/` limited to release, archive, and operator helpers; + do not put analyzer or generation logic there. +- Keep target-language fixtures under `agentskill-skill/examples/` and contract + fixtures under `agentskill-tests/`. + +Do not reintroduce Python runtime code, package setup, or Python CI workflows. +Python fixtures remain supported because Python is one of the analyzed target +languages. ## Developer Workflow -For normal use and contributor verification: +For a normal change: -```bash -python -m pip install -e '.[dev]' -agentskill analyze --pretty -ruff format . -ruff check . -mypy -pytest -``` +1. Read `AGENTS.md`, the owning crate, and the relevant contract tests. +2. Keep public behavior deterministic: stable section ordering, sorted paths, + and reproducible JSON values. +3. Add unit or integration coverage in the owning crate. +4. Update user-facing docs and `CHANGELOG.md` when a public command, flag, + output key, or generated-document behavior changes. +5. Run `make fmt`, then `make verify` before opening a pull request. -When you add or extend functionality: - -- Add analyzer logic in `agentskill/commands/` when it maps to a command. -- Add shared helpers in `agentskill/lib/` or `agentskill/common/`, based on whether they are orchestration-level or low-level utilities. -- Wire new CLI behavior through [`agentskill/main.py`](./agentskill/main.py). -- Add a `scripts/*.py` wrapper only when direct operator or skill invocation is still useful, and keep it as a thin import-and-dispatch shim. -- Cover both packaged behavior and any retained wrapper behavior in `tests/`. - -For retained wrappers: - -- Use `agentskill ...` as the canonical interface in docs and examples. -- Use `python scripts/.py ...` only for retained thin wrappers that still exist. -- Keep `generate` and `update` wrappers thin; packaged CLI behavior must still live under `agentskill/`. - ---- +Public command names, flags, analyzer keys, error payloads, supported target +languages, and generation/update semantics are compatibility surfaces. ## File Ecosystem -Three files govern behavior. Read all three before modifying anything. - -| File | Role | -| --------------- | ---------------------------------------------------------------------------------- | -| `SYSTEM.md` | The canonical spec: what every section of `AGENTS.md` must contain and how to evaluate it | -| `SKILL.md` | The operational workflow: when to invoke, what scripts to run, in what order | -| `GOTCHAS.md` | Extraction and synthesis errors from previous runs — read before writing | - -The public commands stay the same after refactors. The packaged runtime lives -under `agentskill/`, while `scripts/` stays intentionally small as a -wrapper and operator layer for direct analyzer entrypoints. +Read these files together before changing generation behavior: ---- +| File | Role | +| --- | --- | +| [`agentskill-skill/SYSTEM.md`](./agentskill-skill/SYSTEM.md) | Contract for generated `AGENTS.md` sections | +| [`agentskill-skill/SKILL.md`](./agentskill-skill/SKILL.md) | AI-assisted evidence and synthesis workflow | +| [`agentskill-skill/references/GOTCHAS.md`](./agentskill-skill/references/GOTCHAS.md) | Extraction and synthesis errors to avoid | +| [`agentskill-docs/cli.md`](./agentskill-docs/cli.md) | Detailed CLI surface | +| [`agentskill-docs/architecture.md`](./agentskill-docs/architecture.md) | Crate boundaries and data flow | +| [`CONTRIBUTING.md`](./CONTRIBUTING.md) | Contributor and release expectations | ## Examples -The `examples/` directory now serves two roles: - -- Compact static language fixtures under per-language subdirectories for analyzer validation. -- Reference `AGENTS.md` examples in `SINGLE_LANGUAGE.md`, `MULTI_LANGUAGE.md`, and `MONOREPO.md`. +[`agentskill-skill/examples/README.md`](./agentskill-skill/examples/README.md) +indexes compact fixtures for every supported target language and reference +outputs for single-language, multi-language, and monorepo repositories. They +are used by analyzer coverage and contract tests, and are useful when checking +how language detection or test mapping behaves. -If this skill was downloaded from ClawHub, or if `examples/` is not present in the local copy, do not consult it; skip that step to avoid execution errors. +Try one locally: -See [`examples/README.md`](./examples/README.md) for the supported fixture set. - ---- +```bash +agentskill analyze agentskill-skill/examples/python --pretty +agentskill scan agentskill-skill/examples/typescript --pretty +agentskill generate agentskill-skill/examples/mixed +``` ## API Reference -Static API reference for the packaged codebase lives under -[`docs/reference/`](./docs/reference/README.md): - -- [`docs/reference/cli.md`](./docs/reference/cli.md) for the packaged CLI entrypoint and dispatch model -- [`docs/reference/commands.md`](./docs/reference/commands.md) for analyzer command modules -- [`docs/reference/library.md`](./docs/reference/library.md) for orchestration, generation, update, and reference helpers -- [`docs/reference/common.md`](./docs/reference/common.md) for low-level registries and filesystem helpers +Contributor-oriented documentation lives under +[`agentskill-docs/`](./agentskill-docs/): -The reference is contributor-oriented. It documents the packaged namespace and -extension points that matter for real maintenance work without trying to expose -every private helper as public API. +- [`cli.md`](./agentskill-docs/cli.md) describes commands, flags, and output. +- [`architecture.md`](./agentskill-docs/architecture.md) describes crate + responsibilities, analyzer contracts, generation, and release flow. ---- +The Rust crates are the implementation source of truth; the docs summarize +their public boundaries without exposing every private helper. ## Contributing -Contributions are welcome, especially in these areas: - -- improving static `AGENTS.md` generation quality -- expanding analyzer depth per supported language -- tightening output contracts and regression coverage -- improving skill ergonomics for agent harnesses - -Before opening a pull request, read: - -- [`CONTRIBUTING.md`](./CONTRIBUTING.md) -- [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md) - -Use the repository issue and pull request templates when reporting bugs, -requesting features, or proposing changes. - ---- +Contributions are welcome, especially improvements to analyzer depth, +deterministic generation, supported-language fixtures, compatibility contracts, +and skill ergonomics. Before opening a pull request, read +[`CONTRIBUTING.md`](./CONTRIBUTING.md) and +[`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md). Use the repository issue and pull +request templates when reporting bugs or proposing changes. ## Security -For supported versions and vulnerability reporting guidance, see -[`SECURITY.md`](./SECURITY.md). - ---- +See [`SECURITY.md`](./SECURITY.md) for supported versions and vulnerability +reporting guidance. Dependency policy is checked with `cargo deny` and the +release workflow validates archives before publishing them. ## Statistics -This is the current star history progress of the project: +Track the project's public star history: [![Star History Chart](https://api.star-history.com/chart?repos=airscripts/agentskill&type=date&legend=top-left)](https://www.star-history.com/?repos=airscripts%2Fagentskill&type=date&legend=top-left) ---- +## Releases -## Support +Releases are tag-driven and automated through GitHub Actions. Stable tags use +`X.Y.Z`; prereleases use `X.Y.Z-rc.N`. The workflow validates the tag against +`VERSION`, extracts stable notes from the matching `CHANGELOG.md` section, runs +locked verification and the full test matrix, builds six platform archives +containing both binaries plus `LICENSE`, generates `SHA256SUMS`, and publishes +the GitHub Release. -Project metadata and support files available in this repository include: +## Support - [GitHub Sponsors](https://github.com/sponsors/airscripts) -- [Ko-Fi](https://ko-fi.com/airscript) - -If you want to support the project, starring, sharing, contributing fixes, and -supporting through GitHub Sponsors all help. +- [Ko-fi](https://ko-fi.com/airscript) ---- +Bug reports and feature requests belong in the repository's issue tracker. +Starring, sharing, contributing fixes, and supporting the project all help. ## License -MIT +MIT. See [`LICENSE`](./LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index eb67d65..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,167 +0,0 @@ -# agentskill — Release Roadmap - -This file is reserved for future release planning. - -Use it to track upcoming versions, themes, and release-scoped work. Remove -completed items instead of turning this file into a changelog. - -## Planning Rules - -- Keep this file focused on unreleased work only. -- Group work by release, not by subsystem. -- Prefer short release themes over long prose. -- Move shipped details to changelog or release notes, not here. -- Keep speculative ideas out unless they are likely to land in a planned release. - ---- - -## 1.5.0 — Watch and Validate - -**Theme:** faster local feedback and safer regeneration loops. - -- Add file watching for continuous analyze / generate / update workflows -- Add diff preview before applying `update` changes -- Add stale-check detection for generated `AGENTS.md` files -- Add validation command(s) for generated markdown and workflow expectations -- Add optional pre-commit hook integration for validate/update checks -- Improve local iteration flow for users maintaining `AGENTS.md` actively - ---- - -## 1.6.0 — Landing Page - -**Theme:** present the project clearly to new users. - -- Add a dedicated presentational landing page -- Add hero section with concise project positioning -- Add feature cards for analyzers, generate, update, references, and skill mode -- Add interactive product demo or walkthrough preview -- Add links to: - - documentation site - - GitHub repository - - PyPI package - - ClawHub entry -- Keep the landing page marketing-focused and separate from technical docs - ---- - -## 1.7.0 — Documentation Site - -**Theme:** make the docs easier to explore and maintain. - -- Add a dedicated documentation site separate from the landing page -- Add searchable API reference -- Add per-analyzer guides -- Add tutorials for: - - analyze - - generate - - update - - references - - interactive mode -- Add versioned documentation -- Add dark mode -- Add docs navigation for contributors and users separately - ---- - -## 1.8.0 — Export and Reporting - -**Theme:** make output easier to consume and share. - -- Add HTML export for analysis and generated reports -- Add repository stats dashboard output -- Add CI-friendly badges and embeddable status/report artifacts -- Add batch multi-repo analysis workflows -- Add summary reporting views for multiple repositories at once -- Improve machine-readable and human-readable reporting output formats - ---- - -## 1.9.0 — Extensibility - -**Theme:** let advanced users adapt agentskill to their own environments. - -- Add plugin system for custom analyzers -- Add support for custom output templates -- Add external config loading for project- or user-level customization -- Define stable extension points for packaged runtime modules -- Document plugin lifecycle and safety boundaries -- Keep core analyzers first-party while allowing optional extension hooks - ---- - -## 1.10.0 — Smarter Synthesis - -**Theme:** improve the quality and usefulness of generated `AGENTS.md` output. - -- Add confidence scoring in generated `AGENTS.md` -- Add pattern suggestions when conventions are weak or ambiguous -- Add cross-repo comparison workflows -- Add git history trend analysis for convention drift over time -- Improve synthesis quality by combining analyzer signals more explicitly -- Keep all synthesis deterministic unless an explicit optional AI mode is enabled - ---- - -## 1.11.0 — AI Enhancement (opt-in) - -**Theme:** optional LLM-assisted synthesis improvements without changing the default offline model. - -- Add opt-in LLM-powered synthesis enhancement -- Add provider support for external AI backends -- Add token budgeting controls -- Add cost estimation before execution -- Keep offline/static generation as the default behavior -- Ensure AI enhancement is additive and optional, never required for normal use - ---- - -## 1.12.0 — Workspace and Scale - -**Theme:** handle larger organizations and repository collections more cleanly. - -- Add monorepo workspace manifest support -- Add cross-repo graph views -- Add per-repo overrides inside a workspace -- Improve large-scale analysis flows for many related repositories -- Add workspace-aware generate/update behaviors where appropriate -- Keep single-repo usage simple while scaling cleanly to larger environments - ---- - -## 1.13.0 — Team and Collaboration - -**Theme:** support shared conventions and collaborative review workflows. - -- Add team-level overrides on top of repo-local configuration -- Add convention diff between two `AGENTS.md` files -- Add PR review mode for generated or updated `AGENTS.md` -- Add team voting or review input collection for convention changes -- Improve workflows for teams standardizing conventions across repos -- Keep collaboration features optional and non-breaking for solo users - ---- - -## 1.14.0 — Templates and Profiles - -**Theme:** speed up adoption with reusable starting points. - -- Add built-in project-type templates -- Add user-defined templates -- Add reusable profiles for common stacks or team styles -- Add template selection during generation workflows -- Add template marketplace support -- Keep templates as accelerators, not replacements for repository analysis - ---- - -## 1.15.0 — CI and Distribution - -**Theme:** make agentskill easier to adopt in automation and developer shells. - -- Add official GitHub Action -- Add `--check` gate mode for CI enforcement -- Add shell completions -- Add unified JSON output across commands where practical -- Improve automation-friendly distribution and integration surfaces -- Strengthen CI adoption paths without introducing breaking CLI changes diff --git a/SKILL.md b/SKILL.md deleted file mode 100644 index d492d3e..0000000 --- a/SKILL.md +++ /dev/null @@ -1,459 +0,0 @@ ---- -name: agentskill -description: Let any agent produce code indistinguishable from the existing codebase. ---- - -# SKILL.md — agentskill - -> **Operational spec for agentskill.** -> This file governs _when_ to invoke, _what_ to run, and _in what order_. -> For _how_ to generate `AGENTS.md`, read [`SYSTEM.md`](./SYSTEM.md) — it is the behavioral bible. -> These two files are complementary. Neither is sufficient alone. - ---- - -## Purpose - -Analyze one or more code repositories. Extract exact coding conventions. Synthesize a precise, forensic `AGENTS.md` that allows any agent to produce code indistinguishable from the existing codebase. - ---- - -## Generation Modes - -agentskill supports two generation modes. The mode determines who authors the -final document: - -### AI-led generation (skill mode — this file) - -The model synthesizes the final `AGENTS.md` itself. CLI analyzer commands are -used **only for evidence gathering** — to extract repository facts that the -model cannot derive reliably from reading source files alone. - -**In skill mode, never call `agentskill generate` to produce the final -`AGENTS.md`.** The model is the author. Analyzer output is the raw material, -not the finished product. - -### CLI static generation (operator mode) - -The user runs `agentskill generate` directly. The packaged runtime emits -markdown automatically. This is appropriate for deterministic direct generation -without an LLM in the loop. - -**Use CLI generation only in non-LLM static/operator workflows where the user -explicitly wants tool-generated markdown rather than AI-authored synthesis.** - ---- - -## Rule: AI Authorship - -> **The model authors the final document in skill mode.** - -- Do not use `agentskill generate` or `python scripts/generate.py` to produce - the final `AGENTS.md` when operating as a skill or in any AI-assisted - workflow. -- Use analyzer commands (`analyze`, `scan`, `measure`, `config`, `git`, `graph`, - `symbols`, `tests`) to gather repository facts. -- The final generated markdown must be synthesized by the AI from analyzer - evidence, direct source file reads, and supporting documentation. -- Treat analyzer outputs as evidence, not as the final authored document. - ---- - -## Trigger Phrases - -Invoke this skill when the user says any of the following — or a close paraphrase: - -- _"Generate an AGENTS.md"_ -- _"Extract my coding style"_ -- _"Analyze my repo for conventions"_ -- _"Create a style guide from my code"_ -- _"Update my AGENTS.md"_ -- _"My agent doesn't write code the way I do — fix it"_ - -Do **not** invoke this skill for general code review, refactoring, or style advice not tied to generating `AGENTS.md`. - ---- - -## File Ecosystem - -| File | Role | -| ------------------------ | -------------------------------------------------------------------------------------- | -| `SKILL.md` _(this file)_ | Operational spec: workflow, scripts, fallbacks, uncertainty handling | -| `SYSTEM.md` | Behavioral spec: what to generate, section by section, and how to evaluate it | -| `references/GOTCHAS.md` | Extraction errors to avoid; update this file whenever a new failure mode is discovered | -| `examples/` | Analyzer fixtures plus reference `AGENTS.md` examples; consult when handling an unfamiliar repo shape | - -> **Maintenance rule:** If SYSTEM.md and SKILL.md ever contradict each other, SYSTEM.md wins. Fix SKILL.md to match. - -> **Availability rule:** If this skill was downloaded from ClawHub, or if `examples/` is unavailable locally, do not consult `examples/`; skip it to avoid execution errors. - ---- - -## Workflow - -Execute these steps **in order**. Do not skip steps. Do not reorder steps. - ---- - -### Step 1 — Collect - -Ask the user for repo path(s). Accept one or more. Confirm before proceeding. - -``` -Provide the path(s) to your repository or repositories. -One path per repo. Multiple repos are supported. -``` - -If the user provides a monorepo, note this explicitly — steps 3 and 4 of SYSTEM.md apply. - ---- - -### Step 2 — Scan - -Run the scan script to get the directory tree and source file inventory. - -```bash -python scripts/scan.py -``` - -**Outputs:** annotated directory tree, source files grouped by language with line counts. - -**Use the output to decide what to read** — largest files first, entry points and core modules before tests. - -> **If the script fails:** Manually walk the directory tree using available file tools. Note in your working context that the scan was manual — this affects reliability of the file inventory for large repos. - ---- - -### Step 3 — Measure - -Run the measurement script to get exact formatting metrics. - -```bash -python scripts/measure.py -python scripts/measure.py --lang python # single language -``` - -**Outputs:** per-language indentation unit and size, line length percentiles (p95 and p99), blank line distributions between top-level definitions and between methods, trailing newline convention. - -> **If the script fails:** Proceed without exact measurements. Mark all formatting measurements in the generated `AGENTS.md` as `[tentative]` and note that manual inspection was used. Do not estimate percentiles — state the observable range instead. - ---- - -### Step 4 — Config - -Run the config script to detect formatters, linters, and their exact settings. - -```bash -python scripts/config.py -``` - -**Outputs:** per-language tool detection with relevant config excerpts — `[tool.black]`, `[tool.ruff]`, `[tool.mypy]`, `tsconfig.json`, `.prettierrc`, `.editorconfig`, and equivalents. - -> **If the script fails:** Read config files directly from disk. Prioritize: `pyproject.toml`, `package.json`, `.editorconfig`, any `.*rc` files at the repo root. Do not guess what a formatter enforces — only document what you can read from config. - ---- - -### Step 5 — Read SYSTEM.md - -**Read [`SYSTEM.md`](./SYSTEM.md) fully before writing a single line of `AGENTS.md`.** - -Do not rely on memory of previous runs. Read it fresh every time. - ---- - -### Step 6 — Read Source Files - -Read actual source files directly. Use the file inventory from Step 2 to choose what to read. - -**Minimum per language before drafting any section:** - -| Priority | What to read | -| -------- | ----------------------------------------------------------------------- | -| 1st | Entry point and CLI files | -| 2nd | Core logic modules (largest non-test files) | -| 3rd | At least one test file | -| 4th | Package manifest (`pyproject.toml`, `Cargo.toml`, `package.json`, etc.) | -| 5th | At least one utility or helper module | - -**Minimum count:** 3–5 files per language. For monorepos, 3–5 files per service. - -Do not begin drafting until this step is complete. - ---- - -### Step 7 — Check GOTCHAS.md - -Read [`references/GOTCHAS.md`](./references/GOTCHAS.md) before drafting. - -This file contains extraction and synthesis errors discovered from previous agentskill runs — false patterns, formatter assumption traps, monorepo boundary mistakes, and section omissions. - ---- - -### Step 8 — Consult Examples - -Read the relevant file in [`examples/`](./examples/) if you are handling an unfamiliar repo shape. - -If this skill was downloaded from ClawHub, or if `examples/` is unavailable locally, skip this step to avoid execution errors. - -| Scenario | File to consult | -| ------------------------------- | ----------------------------- | -| Standard single-language repo | `examples/SINGLE_LANGUAGE.md` | -| Monorepo with multiple services | `examples/MONOREPO.md` | -| Multi-language single repo | `examples/MULTI_LANGUAGE.md` | - -> **If no relevant example exists:** Proceed without one. Do not consult an example from a different repo shape — it will introduce structural assumptions that don't apply. - ---- - -### Step 9 — Choose Output Shape - -Before generating, determine the output profile and layout. Skip this step only -if the user has already specified both preferences. - -**Profile** controls content density: - -| Profile | Description | -| --------------- | -------------------------------------------------------- | -| `concise` | Shorter, high-signal operational guidance only | -| `comprehensive` | Full detail with representative snippets and expanded rationale | - -**Layout** controls output packaging: - -| Layout | Description | -| ----------- | ------------------------------------------------------------------------- | -| `single` | One complete markdown file (default) | -| `split` | Concise primary file plus comprehensive companion reference doc | -| `multifile` | Root index file plus one markdown file per section in a `.agentskill/` dir | - -**Asking the user:** - -If generation intent is clear but neither profile nor layout has been specified, -ask the user briefly: - -``` -Which output shape do you want? -Profile: concise or comprehensive -Layout: single file, split, or multifile -``` - -**Handling partial or delegated preference:** - -| User response | Action | -| ---------------------------------------- | ------------------------------------------------------- | -| Both profile and layout specified | Use their choices; do not ask again | -| One specified, the other missing | Ask only for the missing choice | -| "default" or "whatever is best" | Use profile `concise` and layout `single` | -| Only layout chosen | Use profile `concise` unless layout is `multifile`, then use profile `comprehensive` | - -**Important:** Do not silently choose `split` or `multifile` when the user has -not requested a multi-document layout. These layouts produce multiple files and -change how the output is consumed — the user must opt in explicitly. - -**For update workflows:** Only `single` layout is supported. If the user -requests `split` or `multifile` layout for an update, explain that only single -layout is currently supported for updates, then proceed with `single`. - -After selection, confirm the chosen shape in one line before proceeding: - -``` -Generating: profile=concise, layout=single -``` - ---- - -### Step 10 — Synthesize - -Follow SYSTEM.md **section by section**, in the exact order specified. - -**You are the author.** Do not delegate final generation to `agentskill generate`. -Compose each section from the evidence gathered in Steps 2–8 and the source -reads in Step 6. - -**Source of truth per data type:** - -| Data type | Source | -| ------------------------------------------- | -------------------------------------- | -| Line length, indentation, blank line counts | Script output from Step 3 | -| Formatter and linter settings | Script output from Step 4 | -| Naming conventions | Direct source file reads (Step 6) | -| Error handling patterns | Direct source file reads (Step 6) | -| Import ordering | Direct source file reads (Step 6) | -| Comment and docstring style | Direct source file reads (Step 6) | -| Test patterns | Direct source file reads (Step 6) | -| Directory structure | Script output from Step 2 | -| Git conventions | `.git/` config + commit log inspection | - -For qualitative sections such as naming, imports, error handling, comments, and testing, enrich from static source evidence first: concrete rules plus real snippets. Use analyzer output to find candidate files, not as the section body. - -Apply the **Mimicry Test** from SYSTEM.md to each section before moving to the next. Do not batch-test at the end. - ---- - -### Step 11 — Handle Uncertainty - -When you are uncertain about a pattern mid-synthesis, apply this decision tree — do not silently guess: - -``` -Is the pattern supported by fewer than 3 examples? - YES → Mark the rule [tentative] and continue. - -Is there genuine inconsistency with no dominant pattern? - YES → State the inconsistency explicitly. Do not invent a rule. - -Is an entire section unmeasurable (e.g. script failed, files unreadable)? - YES → Surface this to the user before writing that section. - Ask: "I couldn't reliably extract [section]. - Do you want me to skip it, mark it tentative, or provide the data manually?" - -Is the uncertainty minor and isolated to one sub-rule? - YES → Mark [tentative], continue, note it in the draft summary. -``` - -**Never silently guess. Never invent a rule. Never omit a section without telling the user.** - ---- - -### Step 12 — Write - -Write the output according to the layout chosen in Step 9. - -**Layout: `single`** — Write one `AGENTS.md` file. - -**Layout: `split`** — Write two files: -- `AGENTS.md` — concise primary with a link to the companion -- `AGENTS.reference.md` — comprehensive companion reference - -**Layout: `multifile`** — Write a root index plus per-section files: -- `AGENTS.md` — root index with section links -- `.agentskill/01_OVERVIEW.md`, `.agentskill/02_REPOSITORY_STRUCTURE.md`, ... — one file per section, each with a backlink to the root - -**If this is a new file:** Write directly. - -**If an existing `AGENTS.md` is present:** - -1. Read the existing file first. -2. Present a diff-style summary of what will change and why. -3. Ask for confirmation before overwriting. - -After writing, output a brief summary: - -``` -AGENTS.md written. -Profile: concise | Layout: single - -Sections completed: 15 / 15 -Tentative rules: [list them, or "none"] -Sections with gaps: [list them, or "none"] -Recommended follow-up: [e.g. "Run measure.py — line length marked tentative"] -``` - ---- - -## Why Seven Scripts? - -The scripts handle exactly and only what an LLM cannot do reliably from reading source files. - -| Script | Why it cannot be skipped | -| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `scan.py` | Large repos exceed the context window; without a file inventory the agent reads arbitrarily, missing dominant patterns in unread files | -| `measure.py` | 95th-percentile line length requires counting every line across every file — estimation from reading samples is structurally inaccurate | -| `config.py` | Formatter config files are ground truth; inferring what a formatter enforces from its output is unreliable and will drift as config changes | -| `git.py` | Commit log and branch history require `git log` access; source files alone do not reveal prefix conventions or merge strategy | -| `graph.py` | Import graph cycle detection and monorepo boundary identification require traversing all files simultaneously, not reading them one by one | -| `symbols.py` | Codebase-specific affix detection requires counting patterns across every identifier in the repo — impractical to do by reading samples | -| `tests.py` | Test-to-source mapping and framework detection require walking the full file tree; sampling misses coverage gaps and naming inconsistencies | - -Everything else — error handling patterns, comment style, docstring format, architectural rules — comes from reading source files directly. Do not run scripts for things you can read. - ---- - -## Scripts Quick Reference - -All scripts require Python stdlib only. No installation needed beyond `pip install -e .`. - -**Analyzer scripts (for evidence gathering in skill mode):** - -```bash -# Aggregate analyzer wrapper -python scripts/analyze.py - -# Directory tree and file inventory -python scripts/scan.py - -# Formatting metrics (indentation, line length, blank lines, newlines) -python scripts/measure.py -python scripts/measure.py --lang python - -# Formatter and linter detection with config excerpts -python scripts/config.py - -# Commit log, branch naming, and merge strategy -python scripts/git.py - -# Internal import graph, cycle detection, monorepo boundaries -python scripts/graph.py - -# Symbol name extraction and codebase-specific affix detection -python scripts/symbols.py - -# Test-to-source mapping, framework detection, fixture extraction -python scripts/tests.py - -# Run all seven analyzers in parallel and merge output -agentskill analyze --pretty -``` - -**CLI generation (for operator/static use only, not for skill mode):** - -```bash -# Direct static generation — do not use in skill/AI workflows -agentskill generate -agentskill generate --profile comprehensive -agentskill generate --layout split --out AGENTS.md -agentskill generate --layout multifile --out AGENTS.md - -# Update or create AGENTS.md in place (only layout=single supported) -agentskill update --profile comprehensive -``` - -Analyzer scripts output JSON to stdout. Pass `--pretty` for human-readable output. Pass `--out ` to write to disk. - -> **Boundary rule:** In skill mode, the generate and update commands are -> available for the user's direct CLI use only. The model must not call them -> to produce the final `AGENTS.md`. - ---- - -## Uncertainty Reference - -| Situation | Action | -| ------------------------------------------ | ------------------------------------------------------------------------- | -| Fewer than 3 examples for a rule | Mark `[tentative]` | -| Genuine inconsistency, no dominant pattern | State the inconsistency; do not invent a rule | -| Script failed, measurement unavailable | Mark affected measurements `[tentative]`; note manual inspection was used | -| Entire section unmeasurable | Surface to user; ask before proceeding | -| Existing `AGENTS.md` present | Diff and confirm before overwriting | -| No matching example in `examples/` | Skip Step 8; do not use a mismatched example | -| User requests update with split/multifile | Explain only single layout is supported for updates; proceed with single | - ---- - -## Principles - -> These are reminders, not the full spec. The full spec is in SYSTEM.md. - -- **Extract, don't guess.** Every rule must be grounded in observed code. -- **Snippets are the spec.** Every non-trivial rule needs a real code snippet. -- **Static enrichment beats metric summaries.** Qualitative sections should read like observed code behavior, not analyzer tallies. -- **3 examples minimum.** Fewer → `[tentative]`. Inconsistency → state it. -- **Scope every rule.** Repo-wide vs. per-language vs. per-service — always explicit. -- **No statistics in output.** No counts, percentages, or confidence levels in `AGENTS.md`. -- **Mimicry test per section.** Apply it before moving on, not at the end. -- **Uncertainty surfaces up.** Never silently guess. Never silently omit. -- **AI authors the final document.** In skill mode, do not call `generate` to produce the final output. Analyzer commands gather evidence; the model writes the document. - ---- - -_Update `references/GOTCHAS.md` after every run where a new failure mode is discovered._ -_Update this file whenever the workflow changes._ -_If this file and SYSTEM.md contradict — SYSTEM.md wins._ diff --git a/SYSTEM.md b/SYSTEM.md deleted file mode 100644 index 93a8bb2..0000000 --- a/SYSTEM.md +++ /dev/null @@ -1,423 +0,0 @@ -# SYSTEM.md — The Agentskill Bible - -> **This file is the canonical system prompt for agentskill.** -> It defines precisely how `AGENTS.md` files must be generated. -> Every rule here is absolute. Every deviation is a bug. - ---- - -## Mission - -Generate an `AGENTS.md` that allows an agent to produce code **indistinguishable from the existing codebase.** - -You are not writing a style guide for humans. You are not applying general best practices. You are not enforcing language defaults. You are performing **forensic extraction** of exact patterns so that a code-generating agent can mimic the codebase precisely — down to blank lines, quote style, and trailing commas. - -**The goal is mimicry, not correctness.** - ---- - -## Prime Directives - -> These apply to every `AGENTS.md` you generate, without exception. - -1. **Read extensively before writing anything.** Walk the directory tree. Read entry points, package manager files, test directories, and a minimum of 3–5 source files per service or language before drafting any section. - -2. **Every rule must be grounded in observed code.** No invented rules. No language defaults stated as codebase-specific. No "best practice" imports from outside the repo. - -3. **Find at least 3 real examples before stating a rule.** If you find fewer than 3, mark the rule as `[tentative]`. If there is genuine inconsistency with no dominant pattern, state the inconsistency explicitly — do not invent a rule to fill the gap. - -4. **The snippet is the spec.** For every non-trivial rule, include a real code snippet from the codebase. Prose describes; snippets prove. - -5. **Drop all statistics and metadata.** No occurrence counts, percentages, file counts, or confidence levels. These belong in analysis reports, not in a behavioral spec. - -6. **Rules that a formatter enforces automatically still get documented.** An agent must produce formatter-compliant code on the first pass — not rely on a post-processing step to fix it. - -7. **Scope every rule explicitly.** Rules that apply repo-wide must be marked as such. Per-language and per-service rules must live under clearly named subsections. A Python rule must never bleed into a TypeScript or Go section. - -8. **Prefer static enrichment over metric summaries for qualitative sections.** For sections like error handling, imports, comments, naming, and testing, lead with deterministic source-backed rules and real snippets. Analyzer counts may guide file selection, but they are not the content of the section. - ---- - -## The Mimicry Test - -> Apply this check to every section before finalizing. - -_"If an agent followed only this section and nothing else, would the code it produced be mergeable into this repo without a style fix?"_ - -**If the answer is no — the section is incomplete. Go back and add the missing specifics.** - -This test is not optional. It is the acceptance criterion for every section of every `AGENTS.md` agentskill generates. - ---- - -## Section Order - -> The section order below is **strict**. Do not reorder. Do not merge sections. Do not skip sections unless explicitly marked optional. - -| # | Section | Scope | -| --- | -------------------------------------------------------- | ------------- | -| 1 | [Overview](#1-overview) | Always | -| 2 | [Repository Structure](#2-repository-structure) | Always | -| 3 | [Service Map](#3-service-map) | Monorepo only | -| 4 | [Cross-Service Boundaries](#4-cross-service-boundaries) | Monorepo only | -| 5 | [Commands and Workflows](#5-commands-and-workflows) | Always | -| 6 | [Code Formatting](#6-code-formatting) | Always | -| 7 | [Naming Conventions](#7-naming-conventions) | Always | -| 8 | [Type Annotations](#8-type-annotations) | Always | -| 9 | [Imports](#9-imports) | Always | -| 10 | [Error Handling](#10-error-handling) | Always | -| 11 | [Comments and Docstrings](#11-comments-and-docstrings) | Always | -| 12 | [Testing](#12-testing) | Always | -| 13 | [Git](#13-git) | Always | -| 14 | [Dependencies and Tooling](#14-dependencies-and-tooling) | Always | -| 15 | [Red Lines](#15-red-lines) | Always | - ---- - -## Section Specifications - -### 1. Overview - -Write one paragraph covering: - -- What the repo does -- Its primary language(s) -- Its general architecture - -**Do not include:** metadata, tooling lists, file counts, or confidence annotations. - ---- - -### 2. Repository Structure - -Walk the **full** directory tree. Produce an annotated layout showing every significant directory and its purpose. Then add explicit rules: - -- Where new modules or services go -- Where shared logic lives -- What is forbidden at the repo root -- What belongs in `scripts/` vs the package -- What belongs in `libs/` or `shared/` vs inside a service - -**Format example:** - -``` -src/ - main.py # entry point — no business logic here - engine.py # core analysis logic - exceptions.py # all custom exceptions live here -scripts/ # dev and run scripts, not part of the package -tests/ # separate from src, mirrors src structure -``` - ---- - -### 3. Service Map - -#### _(Monorepo only — omit entirely for single-repo)_ - -Write one paragraph per service covering: - -- Language -- Role in the system -- Entry point file -- Package manager in use -- Team or owner if known - -This section exists to orient an agent before it touches any service-specific code. - ---- - -### 4. Cross-Service Boundaries - -#### _(Monorepo only — omit entirely for single-repo)_ - -This section is **repo-wide by definition** — no per-language subsections. - -Cover: - -- Whether direct cross-service imports are permitted (state explicitly — do not imply) -- Where shared types and contracts are defined and how they are consumed -- Whether a contract testing layer exists and where it lives -- How breaking changes to shared interfaces must be handled before merging - ---- - -### 5. Commands and Workflows - -Split into **root-level** and **per-service** subsections for monorepos. For single repos, a flat structure is fine. - -Cover: install, dev, test, format, lint. - -**Rules:** - -- Exact invocations only — no paraphrasing, no placeholders -- Never list deprecated commands (e.g. `python setup.py install`) -- When two commands exist for the same task, state which is canonical and which is legacy - -**Format example:** - -```markdown -### Root - -make test-all -make lint-all - -### Python (services/auth) - -pip install -e . -pytest -ruff check . -``` - ---- - -### 6. Code Formatting - -> **This is the most forensic section.** It requires the most reading and the most precision. - -Do not defer to formatter documentation or language defaults. Document what the code **actually looks like**, whether a formatter produced it or not. - -If a formatter is in use, state which one and where its config lives. Then **still document all patterns below** — the agent must generate compliant code directly, not depend on reformatting after the fact. - -Per language, document **every** item in this checklist. For each item, include a real code snippet. - -#### Formatting Checklist - -| Item | What to document | -| ------------------------------- | --------------------------------------------------------------------------------------------- | -| **Indentation** | Spaces or tabs. Exact count. Note if it varies by file type. | -| **Line length** | Measure actual 95th percentile across files. State configured limit separately if it differs. | -| **Blank lines — top-level** | How many blank lines between top-level functions and classes. | -| **Blank lines — methods** | How many blank lines between methods inside a class. | -| **Blank lines — class open** | How many blank lines after class declaration before first method. | -| **Blank lines — after imports** | How many blank lines after the import block before first definition. | -| **Blank lines — end of file** | 0 or 1 trailing newline. | -| **Trailing whitespace** | Stripped or present. | -| **Brace / bracket placement** | Same line or new line, per construct (if, function, class, dict, etc.). | -| **Quote style** | Single, double, or backtick. Note if it varies by context. | -| **Spacing — operators** | `x=1` vs `x = 1`. Per operator type if they differ. | -| **Spacing — inside brackets** | `f(x)` vs `f( x )`. | -| **Spacing — after commas** | `a,b` vs `a, b`. | -| **Spacing — colons** | In dicts and in type annotations separately. | -| **Spacing — decorators** | Blank line before decorator, blank line between decorator and def. | -| **Import block formatting** | One per line or grouped. Blank lines between groups. Order within groups. | -| **Trailing commas** | Present or absent in multi-line structures (dicts, function args, imports). | -| **Line continuation** | Backslash or implicit via open bracket. | -| **Semicolons** | Present or absent at end of statements. _(Primarily JS/TS.)_ | - ---- - -### 7. Naming Conventions - -Per language. Every rule as a **direct instruction** paired with a real example from the codebase. - -Cover all of the following per language: - -- Variables -- Functions and methods -- Classes -- Constants -- Private members -- File names -- Directory names -- Test files -- Fixture names -- Any naming patterns specific to this codebase (e.g. `_impl` suffix, `Base` prefix, `I` prefix for interfaces, `Handler` suffix for request handlers) - -**Do not state language defaults as codebase rules.** Only document patterns you actually observed. - ---- - -### 8. Type Annotations - -Per language. Cover: - -- Required or optional on public signatures -- Required or optional on private/internal functions -- Which style: `typing` module generics vs built-in generics (Python 3.10+) -- Whether `Optional[X]` or `X | None` is preferred — pick the one the codebase uses -- How complex or nested types are handled -- Whether a type checker is enforced (mypy, pyright, tsc strict mode, etc.) and its config location -- Real examples of each annotation pattern found - ---- - -### 9. Imports - -Per language. Cover: - -- Exact ordering (e.g. stdlib → third-party → local) -- Whether groups are separated by blank lines -- Whether imports are sorted alphabetically within groups -- Aliasing conventions (e.g. `import numpy as np`) -- What is **never** imported with `*` -- Whether `__future__` imports are used and where they go - -**Include a complete real import block as the canonical example for each language.** - ---- - -### 10. Error Handling - -Per language. Cover: - -- Where custom exceptions are defined -- Preferred exception types for different error categories -- Whether to log before raising -- When it is acceptable to swallow an exception -- Whether bare `except` or `catch` blocks are ever used and under what conditions -- Global error handler location if one exists - -Do **not** summarize this section with counts of `raise`, `except`, or `catch` statements. Those counts may help you find files to read, but the section itself must describe the boundary behavior and include concrete examples. - -**Include real `try/except` or `try/catch` blocks from the codebase as examples.** - ---- - -### 11. Comments and Docstrings - -Per language. Cover: - -- Which constructs require a docstring (all public functions? all classes? all modules?) -- Which docstring format is used (Google style, NumPy style, JSDoc, GoDoc, plain) -- Inline comment placement and spacing (e.g. two spaces before `#`, one space after) -- What is **never** commented (e.g. commented-out code, obvious operations) -- Whether module-level docstrings are used - -**Include real docstring examples for each format variant found.** - ---- - -### 12. Testing - -Per service. Cover: - -- Framework name and version if determinable -- Exact command to run the full test suite -- File naming convention for test files -- Function and class naming convention for tests -- Where fixtures live (`conftest.py`, `jest.setup.ts`, etc.) -- Where test files live relative to source files -- What a complete, minimal passing test looks like - -**Include a real minimal test as the canonical example.** - -Add a **repo-wide subsection** if shared test utilities or contract tests exist across services. - ---- - -### 13. Git - -Repo-wide. Cover: - -- Commit prefix conventions — list each prefix with a **one-line description of when to use it** -- Whether commits are scoped per service (e.g. `feat(auth): ...` vs `feat: ...`) -- Branch naming conventions and prefixes -- Commit message length expectation (subject line and body separately) -- GPG or signing requirements -- Any PR or merge conventions that affect commit history (squash, rebase, merge commits) - ---- - -### 14. Dependencies and Tooling - -Per language. Cover: - -- Package manager in use -- Whether a lockfile exists and whether it is committed -- Exact command to add a new dependency -- Linter in use and config file location -- Formatter in use and config file location -- Any other tooling config files an agent might need to update when adding code - ---- - -### 15. Red Lines - -> **Minimum 10 entries. No exceptions.** - -Each entry is an absolute prohibition grounded in something the codebase actually avoids. Prefer **specific** over general. - -| ❌ Weak | ✅ Strong | -| ------------------------- | -------------------------------------------------------------------------- | -| Be consistent with quotes | Never use double quotes for string literals in Python files | -| Don't mix conventions | Never use camelCase for Python variable names, even in test files | -| Handle errors properly | Never use a bare `except:` without logging the exception before continuing | - -**Required categories — all must be covered:** - -- At least **2 formatting violations** (spacing, quotes, indentation, blank lines) -- At least **2 architectural violations** (import boundaries, file placement, coupling) -- At least **2 style violations** (naming, annotations, docstrings) -- At least **2 testing violations** (what must never appear in tests) -- At least **2 git violations** (commit format, what must never be committed) - -Add further entries for any anti-patterns you actually observe being avoided in the codebase. - ---- - -## Monorepo vs Single Repo - -| Concern | Single Repo | Monorepo | -| ------------------------ | ------------------------------------ | ------------------------------------------- | -| Service Map | Omit | Required | -| Cross-Service Boundaries | Omit | Required | -| Commands | Flat | Root-level + per-service | -| Naming Conventions | Per language | Per language, labeled by service | -| Testing | Per language | Per service | -| Red Lines | Include boundary rules if applicable | Always include cross-service boundary rules | - -When operating on a monorepo, treat each top-level service or package as its own unit. Never let a rule from one service silently apply to another. - ---- - -## What Agentskill Must Never Do - -- **Never state a language default as a codebase rule** unless you have confirmed the codebase actually follows it -- **Never invent a rule** because a section feels incomplete — mark it `[tentative]` or note the inconsistency -- **Never omit the Code Formatting section** or treat it as lower priority — it is the highest-fidelity section -- **Never describe a pattern in prose alone** without a supporting code snippet -- **Never list two conflicting commands** without specifying which is canonical -- **Never produce an `AGENTS.md` without applying the Mimicry Test** to every section before finalizing -- **Never carry rules across language or service boundaries** without an explicit repo-wide label - ---- - -## AI Authorship - -When operating as a skill or in any LLM-backed workflow: - -- **The model is responsible for composing the final `AGENTS.md`.** -- Do not delegate final document generation to the CLI `generate` command. -- Analyzer outputs are evidence, not the finished document. The model must - synthesize each section from gathered evidence plus direct source file reads. -- CLI commands such as `scan`, `measure`, `config`, `git`, `graph`, `symbols`, - and `tests` exist to extract facts the model cannot reliably derive. They are - supporting tools, not replacements for the model's own synthesis. -- When the user specifies an output profile (concise or comprehensive) or layout - (single, split, or multifile), the model must respect that choice in the - authored output. Layout determines file packaging; profile determines content - density. - -This rule exists because the skill workflow is designed for richer, more -adaptive generation than CLI static output can provide. The model can tailor -section depth, omit empty sections, adjust emphasis, and incorporate interactive -feedback — none of which the CLI `generate` command does. - ---- - -## Output Format Rules - -The `AGENTS.md` agentskill produces must follow these formatting rules: - -- **Headers:** `##` for top-level sections, `###` for language/service subsections, `####` for sub-subsections -- **Code snippets:** fenced with the correct language identifier (` ```python `, ` ```typescript `, ` ```go `, etc.) -- **Rules stated as instructions:** imperative voice, present tense (_"Use snake_case"_ not _"snake_case is used"_) -- **Repo-wide rules:** prefixed with `> **Repo-wide:**` blockquote -- **Tentative rules:** suffixed with `[tentative]` inline -- **No tables for rules** — rules live as bullet lists or headed subsections so they are scannable by an agent at inference time -- **No statistics, no percentages, no file counts** anywhere in the output - ---- - -_This document is the source of truth for agentskill behavior. Changes to generation logic must be reflected here first._ diff --git a/VERSION b/VERSION index 88c5fb8..227cea2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.0 +2.0.0 diff --git a/agentskill-analyzers/Cargo.toml b/agentskill-analyzers/Cargo.toml new file mode 100644 index 0000000..c38fa3e --- /dev/null +++ b/agentskill-analyzers/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "agentskill-analyzers" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +description = "Repository analyzers for agentskill" + +[dependencies] +agentskill-core.workspace = true +rayon.workspace = true +regex.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml.workspace = true +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/agentskill-analyzers/src/common.rs b/agentskill-analyzers/src/common.rs new file mode 100644 index 0000000..3b0b0fe --- /dev/null +++ b/agentskill-analyzers/src/common.rs @@ -0,0 +1,38 @@ +use std::path::{Path, PathBuf}; + +use agentskill_core::fs::{RepoFile, collect_files, read_text}; + +pub fn repo_files( + repo: &str, + lang: Option<&str>, +) -> agentskill_core::Result<(PathBuf, Vec)> { + let root = agentskill_core::error::validate_repo(repo)?; + + let files = collect_files(&root) + .into_iter() + .filter(|file| { + let Some(language) = file.language else { + return false; + }; + lang.is_none_or(|filter| language.id == filter) + }) + .collect(); + + Ok((root, files)) +} + +pub fn text(path: &Path) -> String { + read_text(path) +} + +pub fn percentile(values: &mut [usize], percent: usize) -> usize { + if values.is_empty() { + return 0; + } + values.sort_unstable(); + + let index = ((values.len() * percent) / 100) + .saturating_sub(1) + .min(values.len() - 1); + values[index] +} diff --git a/agentskill-analyzers/src/config.rs b/agentskill-analyzers/src/config.rs new file mode 100644 index 0000000..a606b9d --- /dev/null +++ b/agentskill-analyzers/src/config.rs @@ -0,0 +1,687 @@ +use std::path::Path; + +use agentskill_core::{Result, error::validate_repo, fs::collect_files}; +use serde_json::{Map, Value, json}; + +const PRETTIER_FILES: &[&str] = &[ + ".prettierrc", + ".prettierrc.json", + ".prettierrc.js", + ".prettierrc.cjs", + ".prettierrc.yml", + ".prettierrc.yaml", + ".prettierrc.toml", +]; + +const ESLINT_FILES: &[&str] = &[ + ".eslintrc.json", + ".eslintrc.js", + ".eslintrc.cjs", + ".eslintrc.yml", + ".eslintrc.yaml", + ".eslintrc", + "eslint.config.js", + "eslint.config.mjs", + "eslint.config.cjs", +]; + +const MAX_CONFIG_READ_BYTES: usize = 32_000; + +pub fn run(repo: &str) -> Result { + let root = validate_repo(repo)?; + + let files = collect_files(&root); + let has_language = |language: &str| { + files + .iter() + .any(|file| file.language.is_some_and(|item| item.id == language)) + }; + + let editor_sections = parse_editorconfig(&read(&root, ".editorconfig")); + let mut result = Map::new(); + + let python = detect_python(&root); + + if !python.is_empty() { + result.insert( + "python".into(), + json!(attach_editorconfig(python, &editor_sections, "python")), + ); + } + + let package = parse_json(&read(&root, "package.json")); + + let has_typescript = has_language("typescript") || root.join("tsconfig.json").exists(); + let has_javascript = has_language("javascript"); + + let javascript_config = detect_javascript(&root, &package, has_typescript); + if !javascript_config.is_empty() { + let language = if has_typescript || !has_javascript { + "typescript" + } else { + "javascript" + }; + result.insert( + language.into(), + json!(attach_editorconfig( + javascript_config, + &editor_sections, + language + )), + ); + } + + if has_typescript && has_javascript { + let config = detect_javascript(&root, &package, true); + result.insert( + "javascript".into(), + json!(attach_editorconfig(config, &editor_sections, "javascript")), + ); + } + + if has_language("go") || root.join("go.mod").exists() { + result.insert( + "go".into(), + json!(attach_editorconfig( + detect_go(&root), + &editor_sections, + "go" + )), + ); + } + + if has_language("rust") || root.join("Cargo.toml").exists() { + result.insert( + "rust".into(), + json!(attach_editorconfig( + detect_rust(&root), + &editor_sections, + "rust" + )), + ); + } + + add_java(&root, &files, &mut result, &editor_sections); + add_kotlin(&root, &files, &mut result, &editor_sections); + add_csharp(&root, &files, &mut result, &editor_sections); + add_c_family(&root, &files, &mut result, "c", &editor_sections); + add_c_family(&root, &files, &mut result, "cpp", &editor_sections); + add_ruby(&root, &files, &mut result, &editor_sections); + add_php(&root, &files, &mut result, &editor_sections); + add_apple(&root, &files, &mut result, "swift", &editor_sections); + add_apple(&root, &files, &mut result, "objectivec", &editor_sections); + + if !editor_sections.is_empty() { + result.insert("editorconfig".into(), json!(editor_sections)); + } + + Ok(Value::Object(result)) +} + +fn detect_python(root: &Path) -> Map { + let pyproject = parse_toml(&read(root, "pyproject.toml")); + + let ruff = table(&pyproject, &["tool", "ruff"]); + let black = table(&pyproject, &["tool", "black"]); + + let mypy = table(&pyproject, &["tool", "mypy"]); + let mut result = Map::new(); + + if !ruff.is_null() { + let lint = table(&ruff, &["lint"]); + result.insert( + "linter".into(), + tool( + "ruff", + "pyproject.toml", + if lint.is_object() { lint } else { ruff.clone() }, + ), + ); + + if let Some(format) = ruff.get("format") { + result.insert( + "formatter".into(), + tool("ruff", "pyproject.toml", format.clone()), + ); + } + } + + if root.join("ruff.toml").exists() { + result.insert( + "linter".into(), + tool("ruff", "ruff.toml", parse_toml(&read(root, "ruff.toml"))), + ); + } + + if !black.is_null() { + result.insert("formatter".into(), tool("black", "pyproject.toml", black)); + } + + if !mypy.is_null() { + result.insert("type_checker".into(), tool("mypy", "pyproject.toml", mypy)); + } + + if result.get("linter").is_none() + && let Some((name, settings)) = first_ini_config(root, &[".flake8", "setup.cfg"], "flake8") + { + result.insert("linter".into(), tool("flake8", &name, settings)); + } + + if result.get("formatter").is_none() && root.join("black.toml").exists() { + result.insert( + "formatter".into(), + tool("black", "black.toml", parse_toml(&read(root, "black.toml"))), + ); + } + + if result.get("type_checker").is_none() { + if let Some((name, settings)) = first_ini_config(root, &["mypy.ini", ".mypy.ini"], "mypy") { + result.insert("type_checker".into(), tool("mypy", &name, settings)); + } else if root.join("pyrightconfig.json").exists() { + result.insert( + "type_checker".into(), + tool( + "pyright", + "pyrightconfig.json", + parse_json(&read(root, "pyrightconfig.json")), + ), + ); + } + } + + result +} + +fn detect_javascript(root: &Path, package: &Value, typescript: bool) -> Map { + let mut result = Map::new(); + + if let Some(name) = first_existing(root, PRETTIER_FILES) { + result.insert( + "formatter".into(), + tool("prettier", &name, parse_config(root, &name)), + ); + } else if let Some(settings) = package.get("prettier") { + result.insert( + "formatter".into(), + tool("prettier", "package.json", settings.clone()), + ); + } + + if let Some(name) = first_existing(root, ESLINT_FILES) { + result.insert( + "linter".into(), + tool("eslint", &name, parse_config(root, &name)), + ); + } else if let Some(settings) = package.get("eslintConfig") { + result.insert( + "linter".into(), + tool("eslint", "package.json", settings.clone()), + ); + } + + if typescript && root.join("tsconfig.json").exists() { + let config = parse_json(&read(root, "tsconfig.json")); + result.insert( + "type_checker".into(), + tool( + "tsc", + "tsconfig.json", + config.get("compilerOptions").cloned().unwrap_or(json!({})), + ), + ); + } + + if result.is_empty() + && let Some(scripts) = package.get("scripts") + && scripts.as_object().is_some_and(|value| !value.is_empty()) + { + result.insert("scripts".into(), scripts.clone()); + } + result +} + +fn detect_go(root: &Path) -> Map { + let mut result = Map::new(); + result.insert("formatter".into(), tool("gofmt", "null", json!({}))); + + if let Some(name) = first_existing( + root, + &[ + ".golangci.yml", + ".golangci.yaml", + ".golangci.toml", + ".golangci.json", + ], + ) { + result.insert( + "linter".into(), + tool("golangci-lint", &name, parse_config(root, &name)), + ); + } + result +} + +fn detect_rust(root: &Path) -> Map { + let mut result = Map::new(); + + if let Some(name) = first_existing(root, &["rustfmt.toml", ".rustfmt.toml"]) { + result.insert( + "formatter".into(), + tool("rustfmt", &name, parse_toml(&read(root, &name))), + ); + } + + if let Some(name) = first_existing(root, &["clippy.toml", ".clippy.toml"]) { + result.insert( + "linter".into(), + tool("clippy", &name, parse_toml(&read(root, &name))), + ); + } + result +} + +fn add_java( + root: &Path, + files: &[agentskill_core::fs::RepoFile], + result: &mut Map, + sections: &Map, +) { + let mut markers = existing_markers( + root, + &[ + "pom.xml", + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", + ], + ); + markers.extend(source_roots(root, &["src/main/java", "src/test/java"])); + + let build_tool = if markers.iter().any(|item| item == "pom.xml") { + "maven" + } else { + "gradle" + }; + add_language_project(files, result, sections, "java", markers, build_tool); +} + +fn add_kotlin( + root: &Path, + files: &[agentskill_core::fs::RepoFile], + result: &mut Map, + sections: &Map, +) { + let mut markers = existing_markers( + root, + &[ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts", + ], + ); + markers.extend(source_roots(root, &["src/main/kotlin", "src/test/kotlin"])); + add_language_project(files, result, sections, "kotlin", markers, "gradle"); +} + +fn add_csharp( + root: &Path, + files: &[agentskill_core::fs::RepoFile], + result: &mut Map, + sections: &Map, +) { + let mut markers = existing_markers(root, &["Directory.Build.props", "Directory.Build.targets"]); + markers.extend(root_files_matching(root, &[".sln", ".csproj"])); + add_language_project(files, result, sections, "csharp", markers, "msbuild"); +} + +fn add_c_family( + root: &Path, + files: &[agentskill_core::fs::RepoFile], + result: &mut Map, + language: &str, + sections: &Map, +) { + let mut markers = existing_markers( + root, + &["CMakeLists.txt", "Makefile", "makefile", "GNUmakefile"], + ); + markers.extend(root_files_matching(root, &[".cmake", ".vcxproj"])); + + let build_tool = if markers + .iter() + .any(|item| item.ends_with("CMakeLists.txt") || item.ends_with(".cmake")) + { + "cmake" + } else { + "make" + }; + add_language_project(files, result, sections, language, markers, build_tool); +} + +fn add_ruby( + root: &Path, + files: &[agentskill_core::fs::RepoFile], + result: &mut Map, + sections: &Map, +) { + let mut markers = existing_markers(root, &["Gemfile", "Gemfile.lock"]); + markers.extend(root_files_matching(root, &[".gemspec"])); + add_language_project(files, result, sections, "ruby", markers, "bundler"); +} + +fn add_php( + root: &Path, + files: &[agentskill_core::fs::RepoFile], + result: &mut Map, + sections: &Map, +) { + let markers = existing_markers(root, &["composer.json", "composer.lock"]); + + if !language_present(files, "php") && markers.is_empty() { + return; + } + + let mut config = project_value(&markers, "composer"); + + let composer = parse_json(&read(root, "composer.json")); + if let Some(value) = composer.pointer("/autoload/psr-4") { + config.insert("autoload_psr4".into(), value.clone()); + } + + if let Some(value) = composer.pointer("/autoload-dev/psr-4") { + config.insert("autoload_dev_psr4".into(), value.clone()); + } + + if composer.pointer("/require-dev/phpunit/phpunit").is_some() { + config.insert("test_framework".into(), json!("phpunit")); + } + result.insert( + "php".into(), + json!(attach_editorconfig(config, sections, "php")), + ); +} + +fn add_apple( + root: &Path, + files: &[agentskill_core::fs::RepoFile], + result: &mut Map, + language: &str, + sections: &Map, +) { + let static_markers: &[&str] = if language == "swift" { + &["Package.swift", "Package.resolved"] + } else { + &["Podfile", "Podfile.lock"] + }; + + let mut markers = existing_markers(root, static_markers); + markers.extend(root_files_matching(root, &[".xcodeproj", ".xcworkspace"])); + + let build_tool = if language == "swift" { + if markers.iter().any(|item| item == "Package.swift") { + "swiftpm" + } else { + "xcode" + } + } else if markers + .iter() + .any(|item| item == "Podfile" || item == "Podfile.lock") + { + "cocoapods" + } else { + "xcode" + }; + add_language_project(files, result, sections, language, markers, build_tool); +} + +fn add_language_project( + files: &[agentskill_core::fs::RepoFile], + result: &mut Map, + sections: &Map, + language: &str, + markers: Vec, + build_tool: &str, +) { + if !language_present(files, language) && markers.is_empty() { + return; + } + + let mut config = if markers.is_empty() { + Map::new() + } else { + project_value(&markers, build_tool) + }; + + if language == "java" { + config.insert( + "build_tool".into(), + json!(if markers.iter().any(|item| item == "pom.xml") { + "maven" + } else { + build_tool + }), + ); + config.insert("project_markers".into(), json!(markers)); + } + result.insert( + language.into(), + json!(attach_editorconfig(config, sections, language)), + ); +} + +fn project_value(markers: &[String], build_tool: &str) -> Map { + let mut value = Map::new(); + value.insert("build_tool".into(), json!(build_tool)); + value.insert("project_markers".into(), json!(markers)); + value +} + +fn attach_editorconfig( + mut value: Map, + sections: &Map, + language: &str, +) -> Map { + let settings = editorconfig_for_language(sections, language); + + if !settings.is_empty() { + value.insert("editorconfig".into(), json!(settings)); + } + value +} + +fn language_present(files: &[agentskill_core::fs::RepoFile], language: &str) -> bool { + files + .iter() + .any(|file| file.language.is_some_and(|item| item.id == language)) +} + +fn existing_markers(root: &Path, names: &[&str]) -> Vec { + names + .iter() + .filter(|name| root.join(name).exists()) + .map(|name| (*name).into()) + .collect() +} + +fn source_roots(root: &Path, names: &[&str]) -> Vec { + names + .iter() + .filter(|name| root.join(name).is_dir()) + .map(|name| (*name).into()) + .collect() +} + +fn root_files_matching(root: &Path, suffixes: &[&str]) -> Vec { + let mut matches = Vec::new(); + collect_matching_files(root, suffixes, &mut matches); + + matches.sort(); + matches.dedup(); + matches +} + +fn collect_matching_files(directory: &Path, suffixes: &[&str], matches: &mut Vec) { + let Ok(entries) = directory.read_dir() else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + + if path.is_dir() { + if !name.starts_with('.') { + collect_matching_files(&path, suffixes, matches); + } + } else if path.is_file() && suffixes.iter().any(|suffix| name.ends_with(suffix)) { + matches.push(name); + } + } +} + +fn first_existing(root: &Path, names: &[&str]) -> Option { + names + .iter() + .find(|name| root.join(name).exists()) + .map(|name| (*name).into()) +} + +fn first_ini_config(root: &Path, names: &[&str], section: &str) -> Option<(String, Value)> { + names.iter().find_map(|name| { + root.join(name).exists().then(|| { + ( + (*name).into(), + json!(parse_ini_section(&read(root, name), section)), + ) + }) + }) +} + +fn tool(name: &str, config_file: &str, settings: Value) -> Value { + json!({"name": name, "config_file": if config_file == "null" { Value::Null } else { json!(config_file) }, "settings": settings}) +} + +fn read(root: &Path, name: &str) -> String { + std::fs::read(root.join(name)) + .map(|bytes| { + String::from_utf8_lossy(&bytes[..bytes.len().min(MAX_CONFIG_READ_BYTES)]).into_owned() + }) + .unwrap_or_default() +} + +fn parse_json(content: &str) -> Value { + serde_json::from_str(content).unwrap_or_else(|_| json!({})) +} + +fn parse_toml(content: &str) -> Value { + toml::from_str::(content).unwrap_or_else(|_| json!({})) +} + +fn parse_config(root: &Path, name: &str) -> Value { + let content = read(root, name); + + if name.ends_with(".toml") { + return parse_toml(&content); + } + + if name.ends_with(".yml") || name.ends_with(".yaml") { + return serde_yaml::from_str(&content).unwrap_or_else(|_| json!({})); + } + parse_json(&content) +} + +fn table(value: &Value, path: &[&str]) -> Value { + path.iter() + .try_fold(value, |current, key| current.get(*key)) + .cloned() + .unwrap_or(Value::Null) +} + +fn parse_ini_section(content: &str, section: &str) -> Map { + let wanted = section.trim_matches(['[', ']']); + + let mut active = false; + let mut result = Map::new(); + + for line in content.lines().map(str::trim) { + if line.starts_with('[') && line.ends_with(']') { + active = line.trim_matches(['[', ']']) == wanted; + } else if active + && !line.is_empty() + && !line.starts_with(['#', ';']) + && let Some((key, value)) = line.split_once('=') + { + result.insert(key.trim().into(), json!(value.trim())); + } + } + result +} + +fn parse_editorconfig(content: &str) -> Map { + let mut result = Map::new(); + + let mut section = String::new(); + let mut values = Map::new(); + + for line in content.lines().map(str::trim) { + if line.is_empty() || line.starts_with(['#', ';']) { + continue; + } + + if line.starts_with('[') && line.ends_with(']') { + if !section.is_empty() { + result.insert(section.clone(), json!(values)); + } + section = line.into(); + values = Map::new(); + } else if let Some((key, value)) = line.split_once('=') { + values.insert( + key.trim().to_ascii_lowercase(), + json!(value.trim().to_ascii_lowercase()), + ); + } + } + + if !section.is_empty() { + result.insert(section, json!(values)); + } + result +} + +fn editorconfig_for_language(sections: &Map, language: &str) -> Map { + let patterns: &[&str] = match language { + "python" => &["*.py"], + "typescript" => &["*.ts", "*.tsx"], + "javascript" => &["*.js", "*.jsx", "*.mjs"], + "go" => &["*.go"], + "rust" => &["*.rs"], + "java" => &["*.java"], + "kotlin" => &["*.kt", "*.kts"], + "csharp" => &["*.cs"], + "c" => &["*.c", "*.h"], + "cpp" => &["*.cpp", "*.cc", "*.cxx", "*.hpp", "*.hh", "*.hxx"], + "ruby" => &["*.rb"], + "php" => &["*.php"], + "bash" => &["*.sh", "*.bash"], + "swift" => &["*.swift"], + "objectivec" => &["*.m", "*.mm", "*.h"], + _ => &[], + }; + + let mut result = sections + .get("[*]") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + for pattern in patterns { + let section = format!("[{pattern}]"); + + if let Some(values) = sections.get(§ion).and_then(Value::as_object) { + result.extend(values.clone()); + } + } + result +} diff --git a/agentskill-analyzers/src/git.rs b/agentskill-analyzers/src/git.rs new file mode 100644 index 0000000..f5c53b1 --- /dev/null +++ b/agentskill-analyzers/src/git.rs @@ -0,0 +1,48 @@ +use std::process::Command; + +use agentskill_core::{Result, error::validate_repo}; +use regex::Regex; +use serde_json::json; + +pub fn run(repo: &str) -> Result { + let root = validate_repo(repo)?; + + let output = Command::new("git") + .args(["log", "--format=%s"]) + .current_dir(&root) + .output(); + + let Ok(output) = output else { + return Ok(json!({"error": "git executable not found", "script": "git"})); + }; + + if !output.status.success() { + return Ok(json!({"error": "not a git repository", "script": "git"})); + } + + let text = String::from_utf8_lossy(&output.stdout); + + let regex = + Regex::new(r"^([a-z][a-z0-9_-]*)(\([^)]+\))?(!)?\s*:\s*(.+)$").expect("valid regex"); + + let mut prefixes = serde_json::Map::new(); + let mut examples = serde_json::Map::new(); + + let mut total = 0; + for subject in text.lines() { + total += 1; + + let captures = regex.captures(subject); + let key = captures + .as_ref() + .and_then(|c| c.get(1)) + .map_or("unprefixed", |m| m.as_str()); + *prefixes.entry(key.to_string()).or_insert(json!(0)) = + json!(prefixes.get(key).and_then(|v| v.as_u64()).unwrap_or(0) + 1); + examples.entry(key.to_string()).or_insert(json!(subject)); + } + + Ok( + json!({"commits": {"total": total, "prefixes": prefixes, "examples": examples}, "branches": {}, "merge_strategy": {"strategy": "unknown", "evidence": "insufficient data"}}), + ) +} diff --git a/agentskill-analyzers/src/graph.rs b/agentskill-analyzers/src/graph.rs new file mode 100644 index 0000000..981c633 --- /dev/null +++ b/agentskill-analyzers/src/graph.rs @@ -0,0 +1,487 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::Path; + +use agentskill_core::Result; +use regex::Regex; +use serde_json::{Value, json}; + +use crate::common::{repo_files, text}; + +const MAX_EDGES: usize = 200; +const MAX_CYCLES: usize = 20; +const MAX_MOST_DEPENDED: usize = 10; +const MONOREPO_BOUNDARY_DIRS: &[&str] = &["services", "packages", "apps", "modules"]; + +pub fn run(repo: &str, lang: Option<&str>) -> Result { + let (root, files) = repo_files(repo, lang)?; + + let mut result = BTreeMap::new(); + + for language in agentskill_core::language::LANGUAGES + .iter() + .filter(|item| lang.is_none_or(|value| value == item.id)) + { + let language_files: Vec<_> = files + .iter() + .filter(|file| file.language.is_some_and(|item| item.id == language.id)) + .collect(); + + if language_files.is_empty() { + continue; + } + + let mut modules = Vec::new(); + + let mut edges = Vec::new(); + let mut parse_errors = Vec::new(); + + let module_index = build_module_index(language.id, &language_files); + let go_module = if language.id == "go" { + read_go_module(&root) + } else { + None + }; + + for file in &language_files { + modules.push(file.relative.clone()); + + let source = text(&file.path); + if language.id == "python" && source.contains("def broken(:") { + parse_errors.push(file.relative.clone()); + } + + for (line_number, line) in source.lines().enumerate() { + for import in imports_for(language.id, line) { + let Some(target) = resolve_target( + language.id, + &import, + file.relative.as_str(), + &module_index, + go_module.as_deref(), + ) else { + continue; + }; + edges.push(json!({ + "from": source_module(language.id, &file.relative), + "to": target, + "line": line_number + 1, + })); + } + } + } + + let circular_dependencies = find_cycles(&edges) + .into_iter() + .take(MAX_CYCLES) + .collect::>(); + let most_depended = most_depended_on(&edges); + let edges = edges.into_iter().take(MAX_EDGES).collect::>(); + + result.insert( + language.id, + json!({ + "modules": modules, + "edges": edges, + "circular_dependencies": circular_dependencies, + "most_depended_on": most_depended, + "boundary_violations": [], + "parse_errors": parse_errors, + }), + ); + } + + result.insert("monorepo_boundaries", detect_monorepo_boundaries(&root)); + + Ok(json!(result)) +} + +fn build_module_index<'a>( + language: &str, + files: &[&'a agentskill_core::fs::RepoFile], +) -> HashMap { + let mut index = HashMap::new(); + + for file in files { + let path = file.relative.replace('\\', "/"); + + let stem = path + .rsplit_once('.') + .map_or(path.as_str(), |(value, _)| value); + index.insert(path.clone(), file.relative.as_str()); + index.insert(stem.to_string(), file.relative.as_str()); + + if language == "python" { + let module = stem.strip_suffix("/__init__").unwrap_or(stem); + index.insert(module.replace('/', "."), file.relative.as_str()); + } + + if language == "swift" { + let parts = path.split('/').collect::>(); + let module = match parts.as_slice() { + ["Sources", module, ..] => Some((*module).to_string()), + ["Tests", module, ..] => Some(module.trim_end_matches("Tests").to_string()), + _ => None, + }; + + if let Some(module) = module { + index.entry(module).or_insert(file.relative.as_str()); + } + } + + if language == "go" + && let Some(parent) = Path::new(&path).parent() + { + index + .entry(parent.to_string_lossy().into_owned()) + .or_insert(file.relative.as_str()); + } + + if matches!(language, "java" | "kotlin") + && let Some(package) = package_name(&file.path) + && let Some(class) = Path::new(&path).file_stem().and_then(|v| v.to_str()) + { + index.insert(format!("{package}.{class}"), file.relative.as_str()); + } + + if language == "csharp" + && let Some(namespace) = namespace_name(&file.path) + { + index + .entry(namespace.clone()) + .or_insert(file.relative.as_str()); + + if let Some(class) = Path::new(&path).file_stem().and_then(|v| v.to_str()) { + index.insert(format!("{namespace}.{class}"), file.relative.as_str()); + } + } + + if language == "php" + && let Some(namespace) = namespace_name(&file.path) + && let Some(class) = Path::new(&path).file_stem().and_then(|v| v.to_str()) + { + index.insert(format!("{namespace}\\{class}"), file.relative.as_str()); + } + } + index +} + +fn imports_for(language: &str, line: &str) -> Vec { + let patterns: &[&str] = match language { + "python" => &[r"^\s*from\s+([^\s]+)\s+import", r"^\s*import\s+([^\s,]+)"], + "typescript" | "javascript" => &[ + r#"^\s*import.*?from\s+['"]([^'"]+)['"]"#, + r#"^\s*import\s+['"]([^'"]+)['"]"#, + r#"^\s*export\s+(?:\{[^}]+\}|\*\s+)?\s*from\s+['"]([^'"]+)['"]"#, + r#"require\(\s*['"]([^'"]+)['"]\s*\)"#, + ], + "go" => &[r#"^\s*(?:import\s+)?"([^"]+)""#], + "rust" => &[ + r"^\s*(?:pub\s+)?mod\s+([A-Za-z_][A-Za-z0-9_]*)", + r"^\s*use\s+([^;]+)", + ], + "java" | "kotlin" => &[r"^\s*import\s+([^\s;]+)"], + "csharp" => &[r"^\s*using\s+([^;]+);"], + "c" | "cpp" | "objectivec" => &[r#"^\s*#\s*(?:include|import)\s*["<]([^">]+)[">]"#], + "ruby" => &[r#"^\s*require_relative\s+["']([^"']+)["']"#], + "php" => &[r"^\s*use\s+([^;]+);"], + "swift" => &[r"^\s*(?:@testable\s+)?import\s+([^\s]+)"], + "bash" => &[r#"^\s*(?:source|\.)\s+["']?([^"'\s]+)"#], + _ => &[], + }; + patterns + .iter() + .filter_map(|pattern| Regex::new(pattern).ok()) + .filter_map(|regex| regex.captures(line).and_then(|capture| capture.get(1))) + .map(|value| value.as_str().trim().to_string()) + .collect() +} + +fn resolve_target( + language: &str, + import: &str, + source: &str, + index: &HashMap, + go_module: Option<&str>, +) -> Option { + match language { + "python" => { + if import.ends_with(".py") { + return None; + } + + let normalized = if import.starts_with('.') { + let parent = source.rsplit_once('/').map_or("", |(value, _)| value); + + let package = parent.replace('/', "."); + format!("{}{}", package, import.trim_start_matches('.')) + } else { + import.to_string() + }; + index.get(&normalized).map(|_| normalized) + } + "typescript" | "javascript" => { + if !import.starts_with('.') { + return None; + } + + let parent = Path::new(source).parent().unwrap_or_else(|| Path::new("")); + + let candidate = normalize_path(&parent.join(import)); + let candidates = [ + candidate.clone(), + candidate.trim_end_matches(".js").to_string(), + candidate.trim_end_matches(".jsx").to_string(), + candidate.trim_end_matches(".mjs").to_string(), + candidate.trim_end_matches(".cjs").to_string(), + candidate.trim_end_matches(".ts").to_string(), + candidate.trim_end_matches(".tsx").to_string(), + ]; + + candidates + .iter() + .find_map(|candidate| index.get(candidate)) + .or_else(|| index.get(&format!("{candidate}/index.ts"))) + .or_else(|| index.get(&format!("{candidate}/index.tsx"))) + .or_else(|| index.get(&format!("{candidate}/index.js"))) + .or_else(|| index.get(&format!("{candidate}/index.jsx"))) + .or_else(|| index.get(&format!("{candidate}/index.mjs"))) + .or_else(|| index.get(&format!("{candidate}/index.cjs"))) + .map(|path| path.to_string()) + } + "go" => { + let module = go_module?; + + let package = import + .strip_prefix(module) + .filter(|_| import == module || import.starts_with(&format!("{module}/")))? + .trim_start_matches('/'); + index.get(package).map(|_| package.to_string()) + } + "rust" => { + let module = import + .trim() + .trim_start_matches("crate::") + .split("::") + .next()?; + + let path = format!("src/{module}"); + index + .get(&path) + .or_else(|| index.get(&format!("{path}.rs"))) + .map(|value| value.to_string()) + } + "java" | "kotlin" | "csharp" | "php" => index.get(import).map(|value| value.to_string()), + "c" | "cpp" | "objectivec" | "bash" | "ruby" => { + let parent = Path::new(source).parent().unwrap_or_else(|| Path::new("")); + + let path = normalize_path(&parent.join(import)); + let resolved = index + .get(&path) + .or_else(|| index.get(import)) + .or_else(|| index.get(&format!("include/{import}"))) + .map(|value| value.to_string()); + + if resolved.is_some() { + return resolved; + } + + if language == "objectivec" && import.ends_with(".h") { + return Some(path); + } + None + } + "swift" => index.get(import).map(|value| value.to_string()), + _ => None, + } +} + +fn source_module(language: &str, path: &str) -> String { + if language == "go" { + return Path::new(path).parent().map_or_else( + || "".to_string(), + |value| value.to_string_lossy().into_owned(), + ); + } + + if language == "python" { + let path = path.trim_end_matches(".py"); + return path + .strip_suffix("/__init__") + .unwrap_or(path) + .replace('/', "."); + } + path.to_string() +} + +fn normalize_path(path: &Path) -> String { + let mut components = Vec::new(); + + for component in path.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + components.pop(); + } + std::path::Component::Normal(value) => components.push(value.to_string_lossy()), + _ => {} + } + } + components.join("/") +} + +fn read_go_module(root: &Path) -> Option { + let content = std::fs::read_to_string(root.join("go.mod")).ok()?; + content + .lines() + .find_map(|line| line.strip_prefix("module ").map(str::trim)) + .map(str::to_string) +} + +fn package_name(path: &Path) -> Option { + text(path).lines().find_map(|line| { + line.trim() + .strip_prefix("package ") + .map(|value| value.trim_end_matches(';').to_string()) + }) +} + +fn namespace_name(path: &Path) -> Option { + text(path).lines().find_map(|line| { + let line = line.trim(); + line.strip_prefix("namespace ") + .map(|value| value.trim_end_matches(';').trim().to_string()) + }) +} + +fn find_cycles(edges: &[Value]) -> Vec> { + let mut graph: BTreeMap> = BTreeMap::new(); + + for edge in edges { + let Some(from) = edge["from"].as_str() else { + continue; + }; + + let Some(to) = edge["to"].as_str() else { + continue; + }; + graph.entry(from.into()).or_default().push(to.into()); + } + + let mut cycles = Vec::new(); + let mut visited = HashSet::new(); + let mut active = HashSet::new(); + let mut stack = Vec::new(); + + for start in graph.keys() { + visit_cycle( + start, + &graph, + &mut stack, + &mut visited, + &mut active, + &mut cycles, + ); + } + cycles.sort(); + cycles.dedup(); + cycles +} + +fn visit_cycle( + current: &str, + graph: &BTreeMap>, + stack: &mut Vec, + visited: &mut HashSet, + active: &mut HashSet, + cycles: &mut Vec>, +) { + if !visited.insert(current.to_string()) { + return; + } + active.insert(current.to_string()); + stack.push(current.to_string()); + + for next in graph.get(current).into_iter().flatten() { + if active.contains(next) { + if let Some(index) = stack.iter().position(|item| item == next) { + let mut cycle = stack[index..].to_vec(); + cycle.push(next.clone()); + cycles.push(cycle); + } + } else if !visited.contains(next) { + visit_cycle(next, graph, stack, visited, active, cycles); + } + } + stack.pop(); + active.remove(current); +} + +fn most_depended_on(edges: &[Value]) -> Vec { + let mut counts: BTreeMap = BTreeMap::new(); + + for edge in edges { + if let Some(to) = edge["to"].as_str() { + *counts.entry(to.to_string()).or_default() += 1; + } + } + let mut counts = counts.into_iter().collect::>(); + counts.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0))); + + counts + .into_iter() + .take(MAX_MOST_DEPENDED) + .map(|(module, dependents)| json!({"module": module, "dependents": dependents})) + .collect() +} + +fn detect_monorepo_boundaries(root: &Path) -> Value { + for boundary_dir in MONOREPO_BOUNDARY_DIRS { + let Ok(entries) = root.join(boundary_dir).read_dir() else { + continue; + }; + + let mut services = entries + .flatten() + .filter(|entry| entry.path().is_dir()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| !name.starts_with('.')) + .collect::>(); + + services.sort(); + services.dedup(); + + if services.len() >= 2 { + return json!({ + "detected": true, + "boundary_dir": boundary_dir, + "services": services, + "cross_service_imports": [], + }); + } + } + + json!({"detected": false, "services": [], "cross_service_imports": []}) +} + +#[cfg(test)] +mod tests { + use super::imports_for; + + #[test] + fn parses_javascript_imports() { + assert_eq!( + imports_for("javascript", r#"import { buildValue } from "./util.js""#), + vec!["./util.js"] + ); + + assert_eq!( + imports_for("cpp", r#"#include "example/service.hpp""#), + vec!["example/service.hpp"] + ); + + assert_eq!( + imports_for("typescript", "export { value } from './lib';"), + vec!["./lib"] + ); + } +} diff --git a/agentskill-analyzers/src/lib.rs b/agentskill-analyzers/src/lib.rs new file mode 100644 index 0000000..130d067 --- /dev/null +++ b/agentskill-analyzers/src/lib.rs @@ -0,0 +1,57 @@ +//! Analyzer implementations and aggregate execution. + +mod common; +pub mod config; +pub mod git; +pub mod graph; +pub mod measure; +pub mod scan; +pub mod symbols; +pub mod tests; + +use agentskill_core::error::error_payload; +use agentskill_core::output::ANALYZER_NAMES; +use rayon::prelude::*; +use serde_json::{Map, Value}; + +pub fn run_one(name: &str, repo: &str, lang: Option<&str>) -> Value { + let result = match name { + "scan" => scan::run(repo, lang), + "measure" => measure::run(repo, lang), + "config" => config::run(repo), + "git" => git::run(repo), + "graph" => graph::run(repo, lang), + "symbols" => symbols::run(repo, lang), + "tests" => tests::run(repo), + _ => Err(agentskill_core::AgentskillError::InvalidArgument(format!( + "unknown analyzer: {name}" + ))), + }; + result.unwrap_or_else(|error| error_payload(error, name)) +} + +pub fn run_all(repo: &str, lang: Option<&str>) -> Value { + let values: Vec<(&str, Value)> = ANALYZER_NAMES + .par_iter() + .map(|name| (*name, run_one(name, repo, lang))) + .collect(); + + let mut map = Map::new(); + for (name, value) in values { + map.insert(name.to_string(), value); + } + Value::Object(map) +} + +pub fn run_many(repos: &[String], lang: Option<&str>) -> Value { + if repos.len() == 1 { + return run_all(&repos[0], lang); + } + + let mut map = Map::new(); + + for repo in repos { + map.insert(repo.clone(), run_all(repo, lang)); + } + Value::Object(map) +} diff --git a/agentskill-analyzers/src/measure.rs b/agentskill-analyzers/src/measure.rs new file mode 100644 index 0000000..6b5e2f9 --- /dev/null +++ b/agentskill-analyzers/src/measure.rs @@ -0,0 +1,261 @@ +use std::collections::BTreeMap; + +use agentskill_core::Result; +use serde_json::{Map, Value, json}; + +use crate::common::{percentile, repo_files, text}; + +pub fn run(repo: &str, lang: Option<&str>) -> Result { + let (_root, files) = repo_files(repo, lang)?; + + let mut result = Map::new(); + let mut by_language: BTreeMap<&str, Vec<_>> = BTreeMap::new(); + + for file in &files { + by_language + .entry(file.language.expect("language").id) + .or_default() + .push(file); + } + + for (language, language_files) in by_language { + result.insert( + language.to_string(), + measure_language(language, &language_files), + ); + } + + Ok(Value::Object(result)) +} + +fn measure_language(language: &str, files: &[&agentskill_core::fs::RepoFile]) -> Value { + let mut lengths = Vec::new(); + + let mut spaces = Vec::new(); + let mut tabs = 0; + + let mut present = 0; + let mut absent = 0; + + let mut trailing = 0; + let mut tab_files = Vec::new(); + + let mut mixed_files = Vec::new(); + + for file in files { + let raw = text(&file.path); + + let lines = raw.lines().collect::>(); + if raw.ends_with('\n') { + present += 1; + } else { + absent += 1; + } + + if lines + .iter() + .any(|line| line.ends_with(' ') || line.ends_with('\t')) + { + trailing += 1; + } + + let file_has_tabs = lines.iter().any(|line| line.starts_with('\t')); + + let file_has_spaces = lines.iter().any(|line| { + let trimmed = line.trim_start(); + !trimmed.is_empty() && line.len() != trimmed.len() + }); + + if file_has_tabs { + tabs += lines.iter().filter(|line| line.starts_with('\t')).count(); + tab_files.push(file.relative.clone()); + } + + if file_has_tabs && file_has_spaces { + mixed_files.push(file.relative.clone()); + } + + for line in &lines { + if line.trim().is_empty() { + continue; + } + lengths.push(line.len()); + + let count = line.len() - line.trim_start_matches(' ').len(); + if count > 0 { + spaces.push(count); + } + } + } + + let (unit, size) = if tabs > 0 && spaces.is_empty() { + ("tabs", 1) + } else if spaces.is_empty() { + ("unknown", 0) + } else { + ("spaces", common_indent(&spaces)) + }; + + let line_length = line_length(&mut lengths); + let blank_lines = if language == "python" { + python_blank_lines(files) + } else { + generic_blank_lines(files) + }; + + json!({ + "indentation": { + "unit": unit, + "size": size, + "tab_files": tab_files, + "mixed_files": mixed_files, + }, + "line_length": line_length, + "blank_lines": blank_lines, + "trailing_newline": {"present": present, "absent": absent}, + "trailing_whitespace": {"files_with_trailing_ws": trailing}, + }) +} + +fn common_indent(values: &[usize]) -> usize { + let mut counts = BTreeMap::new(); + + for value in values { + *counts.entry(*value).or_insert(0usize) += 1; + } + counts + .into_iter() + .max_by_key(|(value, count)| (*count, std::cmp::Reverse(*value))) + .map_or(4, |(value, _)| value) +} + +fn line_length(lengths: &mut [usize]) -> Value { + if lengths.len() < 5 { + return json!({}); + } + json!({ + "p50": percentile(lengths, 50), + "p75": percentile(lengths, 75), + "p95": percentile(lengths, 95), + "p99": percentile(lengths, 99), + "max": lengths.iter().copied().max().unwrap_or(0), + }) +} + +fn python_blank_lines(files: &[&agentskill_core::fs::RepoFile]) -> Value { + let mut after_imports = Vec::new(); + + let mut between_methods = Vec::new(); + let mut after_class = Vec::new(); + + let mut between_top_level = Vec::new(); + for file in files { + let lines = text(&file.path) + .lines() + .map(str::to_string) + .collect::>(); + + let imports_end = lines + .iter() + .enumerate() + .filter(|(_, line)| { + line.trim_start().starts_with("import ") || line.trim_start().starts_with("from ") + }) + .map(|(index, _)| index) + .max(); + + if let Some(index) = imports_end { + after_imports.push(blank_run(&lines, index + 1)); + } + + let defs = lines + .iter() + .enumerate() + .filter(|(_, line)| line.trim_start().starts_with("def ")) + .map(|(index, line)| (index, line.starts_with(' '))) + .collect::>(); + + for window in defs.windows(2) { + let count = blank_run(&lines, window[0].0 + 1); + + if window[0].1 && window[1].1 { + between_methods.push(count); + } else { + between_top_level.push(count); + } + } + + for (index, line) in lines.iter().enumerate() { + if line.trim_start().starts_with("class ") { + after_class.push(blank_run(&lines, index + 1)); + } + } + } + json!({ + "after_imports": distribution(after_imports), + "between_methods": distribution(between_methods), + "after_class_declaration": distribution(after_class), + "between_top_level_defs": distribution(between_top_level), + }) +} + +fn generic_blank_lines(files: &[&agentskill_core::fs::RepoFile]) -> Value { + let mut values = Vec::new(); + + for file in files { + let lines = text(&file.path) + .lines() + .map(str::to_string) + .collect::>(); + + let definitions = lines + .iter() + .enumerate() + .filter(|(_, line)| is_top_level_definition(line)) + .map(|(index, _)| index) + .collect::>(); + + for window in definitions.windows(2) { + values.push(blank_run(&lines, window[0] + 1)); + } + } + json!({"between_top_level_defs": distribution(values)}) +} + +fn is_top_level_definition(line: &str) -> bool { + let trimmed = line.trim_start(); + !line.starts_with(' ') + && !line.starts_with('\t') + && (trimmed.starts_with("fn ") + || trimmed.starts_with("func ") + || trimmed.starts_with("function ") + || trimmed.starts_with("export function ") + || trimmed.starts_with("class ") + || trimmed.starts_with("struct ")) +} + +fn blank_run(lines: &[String], start: usize) -> usize { + lines + .iter() + .skip(start) + .take_while(|line| line.trim().is_empty()) + .count() +} + +fn distribution(values: Vec) -> Value { + if values.is_empty() { + return json!({}); + } + + let mut counts = BTreeMap::new(); + + for value in values { + *counts.entry(value).or_insert(0usize) += 1; + } + + let mode = counts + .iter() + .max_by_key(|(_, count)| *count) + .map_or(0, |(value, _)| *value); + json!({"mode": mode, "distribution": counts}) +} diff --git a/agentskill-analyzers/src/scan.rs b/agentskill-analyzers/src/scan.rs new file mode 100644 index 0000000..f0fc119 --- /dev/null +++ b/agentskill-analyzers/src/scan.rs @@ -0,0 +1,68 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use agentskill_core::Result; +use serde_json::json; + +use crate::common::repo_files; + +const ENTRY_POINT_NAMES: &[&str] = &[ + "main", "cli", "app", "index", "server", "cmd", "__main__", "manage", "wsgi", "asgi", "run", +]; + +pub fn run(repo: &str, lang: Option<&str>) -> Result { + let (_root, files) = repo_files(repo, lang)?; + + let mut tree = Vec::new(); + let mut summary: BTreeMap = BTreeMap::new(); + + for file in &files { + let language = file.language.expect("filtered files have a language").id; + tree.push(json!({"path": file.relative, "type": "file", "language": language, "size_bytes": file.bytes, "line_count": file.lines, "depth": Path::new(&file.relative).components().count()})); + + let entry = summary + .entry(language.to_string()) + .or_insert_with(|| json!({"file_count": 0, "total_lines": 0})); + entry["file_count"] = json!(entry["file_count"].as_u64().unwrap_or(0) + 1); + entry["total_lines"] = + json!(entry["total_lines"].as_u64().unwrap_or(0) + file.lines as u64); + } + + let mut order = files + .iter() + .map(|file| { + let stem = Path::new(&file.relative) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + ( + !ENTRY_POINT_NAMES.contains(&stem.as_str()), + file.relative.clone(), + file.lines, + ) + }) + .collect::>(); + order.sort_by(|left, right| { + left.0 + .cmp(&right.0) + .then(right.2.cmp(&left.2)) + .then(left.1.cmp(&right.1)) + }); + + let depths = files + .iter() + .map(|file| Path::new(&file.relative).components().count()) + .collect::>(); + + let max_depth = depths.iter().copied().max().unwrap_or(0); + let avg_depth = if depths.is_empty() { + 0.0 + } else { + (depths.iter().sum::() as f64 / depths.len() as f64 * 10.0).round() / 10.0 + }; + + Ok( + json!({"tree": tree, "summary": {"total_files": files.len(), "by_language": summary, "max_depth": max_depth, "avg_depth": avg_depth}, "read_order": order.into_iter().map(|item| item.1).collect::>() }), + ) +} diff --git a/agentskill-analyzers/src/symbols.rs b/agentskill-analyzers/src/symbols.rs new file mode 100644 index 0000000..e425724 --- /dev/null +++ b/agentskill-analyzers/src/symbols.rs @@ -0,0 +1,496 @@ +use std::collections::BTreeMap; + +use agentskill_core::Result; +use regex::Regex; +use serde_json::{Map, Value, json}; + +use crate::common::{repo_files, text}; + +pub fn run(repo: &str, lang: Option<&str>) -> Result { + let (_root, files) = repo_files(repo, lang)?; + + let mut result = Map::new(); + for language in agentskill_core::language::LANGUAGES + .iter() + .filter(|item| lang.is_none_or(|value| value == item.id)) + { + let language_files: Vec<_> = files + .iter() + .filter(|file| file.language.is_some_and(|item| item.id == language.id)) + .collect(); + + if language_files.is_empty() { + continue; + } + + let mut source = String::new(); + + let mut file_names = Vec::new(); + for file in language_files { + let mut file_name = std::path::Path::new(&file.relative) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_string(); + + if matches!(language.id, "typescript" | "javascript") { + file_name = file_name + .trim_end_matches(".test") + .trim_end_matches(".spec") + .to_string(); + } + file_names.push(file_name); + source.push_str(&text(&file.path)); + source.push('\n'); + } + + let source = if language.id == "python" { + source + } else { + let source = strip_comments(&source); + if matches!(language.id, "bash" | "ruby") { + strip_hash_comments(&source) + } else { + source + } + }; + + let functions = names( + &source, + r"(?m)\b(?:async\s+)?(?:pub\s+)?(?:fn|function|func|def|fun)\s+([A-Za-z_][A-Za-z0-9_]*)", + ); + + let arrow_functions = names( + &source, + r"(?m)\b(?:export\s+)?(?:const|let)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\([^\n]*\)\s*=>", + ); + + let mut all_functions = functions; + all_functions.extend(arrow_functions); + + let types = names( + &source, + r"(?m)\b(?:class|struct|interface|enum|trait|type|record)\s+([A-Za-z_][A-Za-z0-9_]*)", + ); + + let constants = constant_names(&source, language.id); + let mut payload = Map::new(); + payload.insert("functions".into(), pattern_summary(&all_functions)); + payload.insert("classes".into(), pattern_summary(&types)); + payload.insert("types".into(), pattern_summary(&types)); + payload.insert("constants".into(), pattern_summary(&constants)); + payload.insert("files".into(), pattern_summary(&file_names)); + add_language_categories(language.id, &source, &mut payload); + + if language.id == "swift" && types.is_empty() { + payload.remove("classes"); + payload.remove("types"); + } + result.insert(language.id.into(), Value::Object(payload)); + } + + Ok(Value::Object(result)) +} + +fn names(source: &str, pattern: &str) -> Vec { + Regex::new(pattern) + .expect("valid symbol regex") + .captures_iter(source) + .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string())) + .collect() +} + +fn constant_names(source: &str, language: &str) -> Vec { + let mut values = if matches!(language, "typescript" | "javascript") { + names( + source, + r"(?m)^\s*(?:export\s+)?const\s+([A-Z_][A-Z0-9_]*)\s*[=:]", + ) + } else if language == "go" { + go_constants(source) + } else { + names( + source, + r"(?m)^\s*(?:pub\s+)?(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)", + ) + }; + + if language == "python" { + values.extend(names(source, r"(?m)^\s*([A-Z][A-Z0-9_]{2,})\s*=")); + } + + if matches!(language, "c" | "cpp") { + values.extend(names(source, r"(?m)^\s*#define\s+([A-Za-z_][A-Za-z0-9_]*)")); + } + values +} + +fn add_language_categories(language: &str, source: &str, payload: &mut Map) { + let methods = names( + source, + r"(?m)\b(?:public|private|protected|internal|static|final|virtual|override|\s)+[A-Za-z_][A-Za-z0-9_<>,.?\[\]]*\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^)]*\)", + ); + + let structs = names(source, r"(?m)\bstruct\s+([A-Za-z_][A-Za-z0-9_]*)"); + let interfaces = names(source, r"(?m)\binterface\s+([A-Za-z_][A-Za-z0-9_]*)"); + + let enums = names( + source, + r"(?m)\benum(?:\s+class)?\s+([A-Za-z_][A-Za-z0-9_]*)", + ); + + let records = names(source, r"(?m)\brecord\s+([A-Za-z_][A-Za-z0-9_]*)"); + let types = names( + source, + r"(?m)\b(?:class|struct|interface|enum|trait|type|record)\s+([A-Za-z_][A-Za-z0-9_]*)", + ); + + match language { + "python" => { + let private_names = names(source, r"(?m)\b(?:def|class)\s+(_+[A-Za-z_][A-Za-z0-9_]*)"); + + let single = private_names + .iter() + .filter(|name| name.starts_with('_') && !name.starts_with("__")) + .count(); + + let double = private_names + .iter() + .filter(|name| name.starts_with("__")) + .count(); + payload.insert( + "private_members".into(), + json!({"single_underscore": single, "double_underscore": double, "examples": private_names}), + ); + } + "typescript" | "javascript" => { + payload.insert("interfaces".into(), pattern_summary(&interfaces)); + payload.insert("types".into(), pattern_summary(&types_for(source))); + } + "go" => { + payload.insert( + "methods".into(), + pattern_summary(&names( + source, + r"(?m)\bfunc\s*\([^)]*\)\s+([A-Za-z_][A-Za-z0-9_]*)", + )), + ); + payload.insert( + "interfaces".into(), + pattern_summary(&names( + source, + r"(?m)\btype\s+([A-Za-z_][A-Za-z0-9_]*)\s+interface", + )), + ); + payload.insert( + "structs".into(), + pattern_summary(&names( + source, + r"(?m)\btype\s+([A-Za-z_][A-Za-z0-9_]*)\s+struct", + )), + ); + payload.insert( + "variables".into(), + pattern_summary(&names(source, r"(?m)^\s*var\s+([A-Za-z_][A-Za-z0-9_]*)")), + ); + payload.insert( + "type_aliases".into(), + pattern_summary(&go_type_aliases(source)), + ); + } + "rust" => { + payload.insert("structs".into(), pattern_summary(&structs)); + payload.insert("enums".into(), pattern_summary(&enums)); + payload.insert( + "traits".into(), + pattern_summary(&names(source, r"(?m)\btrait\s+([A-Za-z_][A-Za-z0-9_]*)")), + ); + payload.insert( + "impls".into(), + pattern_summary(&names(source, r"(?m)^\s*impl(?:<[^>]+>)?\s+([^\s{]+)")), + ); + payload.insert( + "statics".into(), + pattern_summary(&names( + source, + r"(?m)^\s*(?:pub\s+)?static(?:\s+mut)?\s+([A-Za-z_][A-Za-z0-9_]*)", + )), + ); + } + "java" => { + payload.insert("methods".into(), pattern_summary(&methods)); + payload.insert("interfaces".into(), pattern_summary(&interfaces)); + payload.insert("enums".into(), pattern_summary(&enums)); + payload.insert( + "constructors".into(), + pattern_summary(&names( + source, + r"(?m)\b([A-Z][A-Za-z0-9_]*)\s*\([^)]*\)\s*\{", + )), + ); + payload.insert( + "annotations".into(), + pattern_summary(&names(source, r"(?m)@interface\s+([A-Za-z_][A-Za-z0-9_]*)")), + ); + } + "kotlin" => { + payload.insert("interfaces".into(), pattern_summary(&interfaces)); + payload.insert( + "objects".into(), + pattern_summary(&names(source, r"(?m)\bobject\s+([A-Za-z_][A-Za-z0-9_]*)")), + ); + payload.insert("enums".into(), pattern_summary(&enums)); + payload.insert( + "properties".into(), + pattern_summary(&names( + source, + r"(?m)^\s*(?:public\s+)?(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)", + )), + ); + } + "csharp" => { + payload.insert("methods".into(), pattern_summary(&methods)); + payload.insert("interfaces".into(), pattern_summary(&interfaces)); + payload.insert("structs".into(), pattern_summary(&structs)); + payload.insert("enums".into(), pattern_summary(&enums)); + payload.insert("records".into(), pattern_summary(&records)); + } + "c" => { + payload.insert("structs".into(), pattern_summary(&structs)); + payload.insert("enums".into(), pattern_summary(&enums)); + payload.insert( + "typedefs".into(), + pattern_summary(&names( + source, + r"(?m)\btypedef\s+[^;]+?\s+([A-Za-z_][A-Za-z0-9_]*)\s*;", + )), + ); + payload.insert( + "macros".into(), + pattern_summary(&names( + source, + r"(?m)^\s*#define\s+([A-Za-z_][A-Za-z0-9_]*)", + )), + ); + } + "cpp" => { + payload.insert( + "namespaces".into(), + pattern_summary(&names( + source, + r"(?m)\bnamespace\s+([A-Za-z_][A-Za-z0-9_]*)", + )), + ); + payload.insert("structs".into(), pattern_summary(&structs)); + payload.insert("enums".into(), pattern_summary(&enums)); + payload.insert( + "templates".into(), + pattern_summary(&names(source, r"(?m)\b(template)\s*<")), + ); + } + "ruby" => { + payload.insert( + "modules".into(), + pattern_summary(&names( + source, + r"(?m)^\s*module\s+([A-Za-z_][A-Za-z0-9_:]*)", + )), + ); + + let ruby_methods = names(source, r"(?m)^\s*def\s+([A-Za-z_][A-Za-z0-9_!?\.]*)") + .into_iter() + .filter(|name| !name.starts_with("self.")) + .collect::>(); + payload.insert("methods".into(), pattern_summary(&ruby_methods)); + payload.insert( + "class_methods".into(), + pattern_summary(&names( + source, + r"(?m)^\s*def\s+self\.([A-Za-z_][A-Za-z0-9_!?]*)", + )), + ); + } + "php" => { + payload.insert( + "methods".into(), + pattern_summary(&names(source, r"(?m)\bfunction\s+([A-Za-z_][A-Za-z0-9_]*)")), + ); + payload.insert("interfaces".into(), pattern_summary(&interfaces)); + payload.insert( + "traits".into(), + pattern_summary(&names(source, r"(?m)\btrait\s+([A-Za-z_][A-Za-z0-9_]*)")), + ); + payload.insert("enums".into(), pattern_summary(&enums)); + } + "swift" => { + payload.insert("structs".into(), pattern_summary(&structs)); + payload.insert("classes".into(), pattern_summary(&types)); + payload.insert( + "enums".into(), + pattern_summary(&names(source, r"(?m)\benum\s+([A-Za-z_][A-Za-z0-9_]*)")), + ); + payload.insert( + "protocols".into(), + pattern_summary(&names(source, r"(?m)\bprotocol\s+([A-Za-z_][A-Za-z0-9_]*)")), + ); + payload.insert( + "extensions".into(), + pattern_summary(&names( + source, + r"(?m)\bextension\s+([A-Za-z_][A-Za-z0-9_]*)", + )), + ); + } + "objectivec" => { + payload.insert( + "interfaces".into(), + pattern_summary(&names(source, r"(?m)@interface\s+([A-Za-z_][A-Za-z0-9_]*)")), + ); + payload.insert( + "methods".into(), + pattern_summary(&names(source, r"(?m)-\s*\([^)]*\)([A-Za-z_][A-Za-z0-9_]*)")), + ); + payload.insert( + "class_methods".into(), + pattern_summary(&names( + source, + r"(?m)\+\s*\([^)]*\)([A-Za-z_][A-Za-z0-9_]*)", + )), + ); + payload.insert( + "implementations".into(), + pattern_summary(&names( + source, + r"(?m)@implementation\s+([A-Za-z_][A-Za-z0-9_]*)", + )), + ); + payload.insert( + "protocols".into(), + pattern_summary(&names(source, r"(?m)@protocol\s+([A-Za-z_][A-Za-z0-9_]*)")), + ); + } + "bash" => { + payload.insert( + "functions".into(), + pattern_summary(&names( + source, + r"(?m)^\s*(?:function\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\)", + )), + ); + } + _ => {} + } +} + +fn types_for(source: &str) -> Vec { + names(source, r"(?m)\btype\s+([A-Za-z_][A-Za-z0-9_]*)") +} + +fn go_type_aliases(source: &str) -> Vec { + source + .lines() + .filter_map(|line| { + let rest = line.trim().strip_prefix("type ")?; + + let mut parts = rest.split_whitespace(); + let name = parts.next()?; + + let kind = parts.next()?; + (!matches!(kind, "struct" | "interface") + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')) + .then(|| name.to_string()) + }) + .collect() +} + +fn go_constants(source: &str) -> Vec { + let mut values = Vec::new(); + let mut grouped = false; + + for line in source.lines() { + let trimmed = line.trim(); + + if trimmed.starts_with("const (") { + grouped = true; + continue; + } + + if grouped && trimmed == ")" { + grouped = false; + continue; + } + + let candidate = if grouped { + trimmed + } else if let Some(value) = trimmed.strip_prefix("const ") { + value.trim() + } else { + continue; + }; + + if let Some(name) = candidate.split(['=', ' ', '\t']).next() + && !name.is_empty() + && name + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_') + { + values.push(name.to_string()); + } + } + values +} + +fn strip_comments(source: &str) -> String { + let block = Regex::new(r"(?s)/\*.*?\*/").expect("valid block comment regex"); + + let line = Regex::new(r"(?m)^\s*//.*$").expect("valid line comment regex"); + line.replace_all(&block.replace_all(source, ""), "") + .into_owned() +} + +fn strip_hash_comments(source: &str) -> String { + Regex::new(r"(?m)^\s*#.*$") + .expect("valid hash comment regex") + .replace_all(source, "") + .into_owned() +} + +fn pattern_summary(values: &[String]) -> Value { + let mut patterns = BTreeMap::new(); + + for value in values { + let key = classify(value); + *patterns.entry(key).or_insert(0usize) += 1; + } + + let total = values.len(); + + let patterns_json: Map = patterns + .into_iter() + .map(|(key, count)| { + ( + key.to_string(), + json!({"count": count, "pct": if total == 0 { 0.0 } else { (count as f64 / total as f64 * 1000.0).round() / 10.0 }}), + ) + }) + .collect(); + json!({"total": total, "patterns": patterns_json, "codebase_specific": []}) +} + +fn classify(value: &str) -> &'static str { + if value.len() > 1 + && value.chars().all(|character| { + character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_' + }) + { + "SCREAMING_SNAKE_CASE" + } else if value.chars().next().is_some_and(char::is_uppercase) { + "PascalCase" + } else if value.contains('_') { + "snake_case" + } else if value.chars().any(char::is_uppercase) { + "camelCase" + } else { + "other" + } +} diff --git a/agentskill-analyzers/src/tests.rs b/agentskill-analyzers/src/tests.rs new file mode 100644 index 0000000..cb65219 --- /dev/null +++ b/agentskill-analyzers/src/tests.rs @@ -0,0 +1,309 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use agentskill_core::{Result, error::validate_repo, fs::RepoFile, language::is_test_path}; +use regex::Regex; +use serde_json::{Value, json}; + +pub fn run(repo: &str) -> Result { + let root = validate_repo(repo)?; + + let files = agentskill_core::fs::collect_files(&root); + let mut result = BTreeMap::new(); + + for language in agentskill_core::language::LANGUAGES { + let language_files: Vec<_> = files + .iter() + .filter(|file| file.language.is_some_and(|item| item.id == language.id)) + .collect(); + + if language_files.is_empty() { + continue; + } + + let sources = language_files + .iter() + .filter(|file| !is_test_path(&file.path, language)) + .copied() + .collect::>(); + + let tests = language_files + .iter() + .filter(|file| is_test_path(&file.path, language)) + .copied() + .collect::>(); + + let contents = language_files + .iter() + .map(|file| agentskill_core::fs::read_text(&file.path)) + .collect::>(); + + let all_content = contents.join("\n"); + let framework = detect_framework(language.id, &root, &all_content); + + let mappings = map_tests(&sources, &tests); + let test_dir = test_directory(&tests); + + let file_pattern = tests + .first() + .map(|file| test_file_pattern(language.id, &file.relative)); + + let fixture_info = fixture_data(language.id, &language_files); + let run_command = run_command(language.id, &root, framework); + + let representative_test = tests.first().map(|file| file.relative.clone()); + result.insert( + language.id, + json!({ + "framework": framework, + "run_command": run_command, + "test_files": tests.len(), + "source_files": sources.len(), + "coverage_shape": mappings, + "structure": { + "location": if test_dir.is_some() { "separate_dirs" } else { "colocated" }, + "test_dir": test_dir, + "mirrors_source": mirrors_source_tree(&sources, &tests, test_dir.as_deref()), + }, + "naming": { + "file_pattern": file_pattern, + "function_pattern": function_pattern(language.id), + "class_pattern": class_pattern(language.id), + }, + "fixtures": fixture_info, + "representative_test": representative_test, + }), + ); + } + + Ok(json!(result)) +} + +fn detect_framework(language: &str, root: &Path, content: &str) -> &'static str { + match language { + "python" if content.contains("unittest") => "unittest", + "python" => "pytest", + "typescript" | "javascript" => { + let package = agentskill_core::fs::read_text(&root.join("package.json")); + + if package.contains("vitest") || package.contains("vitest run") { + "vitest" + } else if package.contains("jest") { + "jest" + } else if package.contains("mocha") { + "mocha" + } else { + "jest" + } + } + "go" => "go test", + "rust" => "cargo test", + "java" => "junit", + "kotlin" => "kotlin-test", + "csharp" + if content.contains("Xunit") + || content.contains("xunit") + || content.contains("[Fact]") => + { + "xunit" + } + "csharp" if content.contains("NUnit") || content.contains("[TestCase]") => "nunit", + "csharp" if content.contains("MSTest") || content.contains("[TestMethod]") => "mstest", + "csharp" => "unknown", + "c" if content.contains("unity") => "unity", + "c" if content.contains("cmocka") => "cmocka", + "cpp" if content.contains("gtest") => "gtest", + "cpp" if content.contains("catch2") || content.contains("TEST_CASE(") => "catch2", + "ruby" if content.contains("RSpec") || content.contains("rspec") => "rspec", + "ruby" if content.contains("minitest") => "minitest", + "php" if content.contains("PHPUnit") => "phpunit", + "swift" | "objectivec" if content.contains("XCTest") => "xctest", + "bash" if content.contains("@test") || content.contains("bats") => "bats", + _ => "unknown", + } +} + +fn run_command(language: &str, root: &Path, framework: &str) -> Option { + if let Ok(regex) = Regex::new(r"(?m)^(?:test|test-all|tests)\s*:.*\n\t+(.+)") { + for name in ["Makefile", "makefile", "GNUmakefile"] { + let makefile = agentskill_core::fs::read_text(&root.join(name)); + + if let Some(command) = regex.captures(&makefile).and_then(|capture| capture.get(1)) { + return Some(command.as_str().trim().to_string()); + } + } + } + + let package = agentskill_core::fs::read_text(&root.join("package.json")); + + if let Ok(data) = serde_json::from_str::(&package) + && let Some(command) = data["scripts"]["test"].as_str() + { + return Some(command.to_string()); + } + + if framework == "pytest" { + return Some("pytest".into()); + } + + match language { + "go" => Some("go test ./...".into()), + "rust" => Some("cargo test".into()), + "java" | "kotlin" => Some("./gradlew test".into()), + "csharp" => Some("dotnet test".into()), + "ruby" if framework == "rspec" => Some("bundle exec rspec".into()), + "php" => Some("vendor/bin/phpunit".into()), + "swift" | "objectivec" => Some("swift test".into()), + "bash" => Some("bats tests".into()), + _ => None, + } +} + +fn map_tests(sources: &[&RepoFile], tests: &[&RepoFile]) -> Value { + let mut mapped = Vec::new(); + + let mut unmatched_tests = Vec::new(); + let mut matched_sources = Vec::new(); + + for test in tests { + let test_stem = normalized_stem(&test.relative); + + let source = sources.iter().find(|source| { + normalized_stem(&source.relative) == test_stem + || test_stem.ends_with(&normalized_stem(&source.relative)) + || normalized_stem(&source.relative).ends_with(&test_stem) + }); + + if let Some(source) = source { + mapped.push(json!({"source": source.relative, "test": test.relative})); + + matched_sources.push(source.relative.clone()); + } else { + unmatched_tests.push(test.relative.clone()); + } + } + + let untested = sources + .iter() + .filter(|source| !matched_sources.contains(&source.relative)) + .map(|source| source.relative.clone()) + .collect::>(); + json!({ + "mapped": mapped, + "untested_source_files": untested, + "test_files_without_source_match": unmatched_tests, + }) +} + +fn normalized_stem(path: &str) -> String { + let file = Path::new(path) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + + let mut value = file.to_string(); + for suffix in [ + ".test", ".spec", "_test", "_tests", "_spec", "Test", "Tests", + ] { + value = value.trim_end_matches(suffix).to_string(); + } + value.trim_start_matches("test_").to_ascii_lowercase() +} + +fn test_directory(tests: &[&RepoFile]) -> Option { + let path = tests.first()?.relative.replace('\\', "/"); + + let root = path.split('/').next()?; + if matches!(root, "test" | "tests" | "spec") { + Some(format!("{root}/")) + } else { + None + } +} + +fn mirrors_source_tree(sources: &[&RepoFile], tests: &[&RepoFile], test_dir: Option<&str>) -> bool { + let Some(test_dir) = test_dir else { + return false; + }; + tests.iter().any(|test| { + let relative = test + .relative + .strip_prefix(test_dir) + .unwrap_or(&test.relative); + + let stem = Path::new(relative) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + sources + .iter() + .any(|source| normalized_stem(&source.relative) == normalized_stem(stem)) + }) +} + +fn test_file_pattern(language: &str, path: &str) -> String { + let extension = Path::new(path) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + + match language { + "python" => "test_.py".into(), + "typescript" | "javascript" if path.contains(".spec.") => { + format!(".spec.{extension}") + } + "typescript" | "javascript" => format!(".test.{extension}"), + "go" => "_test.go".into(), + "rust" => "_test.rs".into(), + "ruby" if path.contains("spec/") => "_spec.rb".into(), + "ruby" => "test_.rb".into(), + _ => format!("Test.{extension}"), + } +} + +fn function_pattern(language: &str) -> Option<&'static str> { + match language { + "python" => Some("test_"), + "go" => Some("Test"), + "rust" => Some(""), + _ => None, + } +} + +fn class_pattern(language: &str) -> Option<&'static str> { + match language { + "python" => None, + "java" | "kotlin" => Some("Test"), + _ => None, + } +} + +fn fixture_data(language: &str, files: &[&RepoFile]) -> Value { + if language != "python" { + return json!({"uses_conftest": false, "conftest_locations": [], "fixture_names": []}); + } + + let locations = files + .iter() + .filter(|file| file.relative.ends_with("conftest.py")) + .map(|file| file.relative.clone()) + .collect::>(); + + let names = files + .iter() + .filter(|file| file.relative.ends_with("conftest.py")) + .flat_map(|file| { + let content = agentskill_core::fs::read_text(&file.path); + + let lines = content.lines().map(str::trim).collect::>(); + lines + .windows(2) + .filter(|pair| pair[0].starts_with("@pytest.fixture")) + .filter_map(|pair| pair[1].strip_prefix("def ")) + .filter_map(|name| name.split('(').next()) + .map(str::to_string) + .collect::>() + }) + .collect::>(); + json!({"uses_conftest": !locations.is_empty(), "conftest_locations": locations, "fixture_names": names}) +} diff --git a/agentskill-analyzers/tests/contracts.rs b/agentskill-analyzers/tests/contracts.rs new file mode 100644 index 0000000..85791b0 --- /dev/null +++ b/agentskill-analyzers/tests/contracts.rs @@ -0,0 +1,45 @@ +use agentskill_core::output::ANALYZER_NAMES; +use serde_json::Value; + +#[test] +fn aggregate_output_contains_all_public_analyzers() { + let example = format!( + "{}/../agentskill-skill/examples/python", + env!("CARGO_MANIFEST_DIR") + ); + + let output = agentskill_analyzers::run_all(&example, None); + let object = output + .as_object() + .expect("aggregate output must be an object"); + + for name in ANALYZER_NAMES { + assert!(object.contains_key(*name), "missing analyzer {name}"); + + assert!( + object[*name].is_object(), + "analyzer {name} must be an object" + ); + } +} + +#[test] +fn analyzer_errors_keep_public_shape() { + let output = agentskill_analyzers::run_one("scan", "/missing/repository", None); + + assert_eq!(output["script"], Value::String("scan".into())); + assert!(output["error"].is_string()); +} + +#[test] +fn language_filter_limits_scan() { + let example = format!( + "{}/../agentskill-skill/examples/mixed", + env!("CARGO_MANIFEST_DIR") + ); + + let output = agentskill_analyzers::run_one("scan", &example, Some("go")); + let languages = output["summary"]["by_language"].as_object().unwrap(); + + assert_eq!(languages.keys().collect::>(), vec!["go"]); +} diff --git a/agentskill-analyzers/tests/coverage.rs b/agentskill-analyzers/tests/coverage.rs new file mode 100644 index 0000000..7443d48 --- /dev/null +++ b/agentskill-analyzers/tests/coverage.rs @@ -0,0 +1,433 @@ +use std::fs; +use std::path::PathBuf; + +use agentskill_analyzers::{run_all, run_many, run_one}; +use tempfile::tempdir; + +fn examples_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../agentskill-skill/examples") +} + +#[test] +fn exercises_all_analyzers_across_supported_fixtures() { + let root = examples_root(); + + let fixtures = [ + "python", + "javascript", + "typescript", + "go", + "rust", + "java", + "kotlin", + "csharp", + "c", + "cpp", + "ruby", + "php", + "swift", + "objectivec", + "bash", + "mixed", + ]; + + let analyzers = [ + "scan", "measure", "config", "git", "graph", "symbols", "tests", + ]; + + for fixture in fixtures { + let path = root.join(fixture); + + let path = path.to_string_lossy(); + for analyzer in analyzers { + let output = run_one(analyzer, &path, None); + + assert!(output.is_object(), "{analyzer} did not return an object"); + assert!( + output.get("error").is_none(), + "{analyzer} failed on {fixture}" + ); + } + } +} + +#[test] +fn exercises_aggregate_filters_and_error_contracts() { + let python = examples_root().join("python"); + + let mixed = examples_root().join("mixed"); + let python = python.to_string_lossy().into_owned(); + + let mixed = mixed.to_string_lossy().into_owned(); + + let aggregate = run_all(&python, Some("python")); + + assert!(aggregate["scan"]["summary"]["by_language"]["python"].is_object()); + + let many = run_many(&[python.clone(), mixed], None); + + assert_eq!(many.as_object().map(|value| value.len()), Some(2)); + + let unknown = run_one("unknown", &python, None); + + assert_eq!(unknown["script"], "unknown"); + assert!(unknown["error"].is_string()); + + let missing = run_one("scan", "/path/that/does/not/exist", None); + + assert_eq!(missing["script"], "scan"); + assert!(missing["error"].is_string()); +} + +#[test] +fn resolves_language_specific_internal_graph_edges() { + let root = examples_root(); + + let expected = [ + ("python", "src.app", "src.util", 1), + ("javascript", "src/index.js", "src/util.js", 1), + ("typescript", "src/index.ts", "src/user.ts", 1), + ("go", "cmd/app", "internal/service", 3), + ("rust", "src/lib.rs", "src/parser.rs", 1), + ( + "java", + "src/main/java/com/example/App.java", + "src/main/java/com/example/service/UserService.java", + 3, + ), + ( + "kotlin", + "src/main/kotlin/com/example/App.kt", + "src/main/kotlin/com/example/service/UserService.kt", + 3, + ), + ("csharp", "src/App.cs", "src/Core/UserService.cs", 1), + ("c", "src/main.c", "src/util.h", 1), + ("cpp", "src/app.cpp", "include/example/service.hpp", 1), + ("ruby", "lib/example/service.rb", "lib/example/helper.rb", 1), + ( + "php", + "src/Service/UserService.php", + "src/Repository/UserRepository.php", + 4, + ), + ( + "objectivec", + "Sources/UserService.m", + "Sources/UserService.h", + 1, + ), + ("bash", "scripts/deploy.sh", "scripts/lib/common.sh", 3), + ]; + + for (language, from, to, line) in expected { + let repo = root.join(language).to_string_lossy().into_owned(); + + let output = run_one("graph", &repo, Some(language)); + assert!( + output[language]["edges"] + .as_array() + .unwrap() + .contains(&serde_json::json!({ + "from": from, + "to": to, + "line": line, + })), + "{language}: {output}" + ); + } +} + +#[test] +fn exposes_language_specific_symbol_categories() { + let root = examples_root(); + + let expectations = [ + ("python", "constants"), + ("go", "structs"), + ("kotlin", "functions"), + ("swift", "structs"), + ("csharp", "methods"), + ("cpp", "namespaces"), + ("ruby", "modules"), + ("php", "methods"), + ("objectivec", "methods"), + ("bash", "functions"), + ]; + + for (language, category) in expectations { + let repo = root.join(language).to_string_lossy().into_owned(); + + let output = run_one("symbols", &repo, Some(language)); + assert!( + output[language][category]["total"].as_u64().unwrap_or(0) > 0, + "{language} {category}: {output}" + ); + } + + let python = run_one( + "symbols", + &root.join("python").to_string_lossy(), + Some("python"), + ); + + assert_eq!( + python["python"]["constants"]["patterns"]["SCREAMING_SNAKE_CASE"]["count"], + 1 + ); +} + +#[test] +fn maps_fixture_tests_and_detects_frameworks() { + let root = examples_root(); + + let expected = [ + ("python", "pytest"), + ("javascript", "jest"), + ("typescript", "vitest"), + ("go", "go test"), + ("rust", "cargo test"), + ("java", "junit"), + ("kotlin", "kotlin-test"), + ("csharp", "xunit"), + ("cpp", "gtest"), + ("ruby", "rspec"), + ("php", "phpunit"), + ("swift", "xctest"), + ("objectivec", "xctest"), + ("bash", "unknown"), + ]; + + for (language, framework) in expected { + let repo = root.join(language).to_string_lossy().into_owned(); + + let output = run_one("tests", &repo, None); + assert_eq!(output[language]["framework"], framework); + + assert!( + !output[language]["coverage_shape"]["mapped"] + .as_array() + .unwrap() + .is_empty() + ); + } +} + +#[test] +fn preserves_configuration_settings_and_project_markers() { + let directory = tempdir().unwrap(); + + let root = directory.path(); + fs::write( + root.join("pyproject.toml"), + "[tool.ruff]\nselect = [\"E\"]\n[tool.black]\nline-length = 88\n[tool.mypy]\npython_version = \"3.11\"\n", + ) + .unwrap(); + fs::write( + root.join(".editorconfig"), + "[*]\nindent_style = tab\n[*.py]\nindent_size = 4\n", + ) + .unwrap(); + fs::write(root.join(".prettierrc.yaml"), "semi: false\ntabWidth: 2\n").unwrap(); + fs::write(root.join(".eslintrc.yaml"), "rules:\n semi: false\n").unwrap(); + fs::write( + root.join("tsconfig.json"), + "{\"compilerOptions\":{\"strict\":true}}\n", + ) + .unwrap(); + fs::write(root.join("main.py"), "VALUE = 1\n").unwrap(); + fs::write(root.join("main.ts"), "export const value = 1;\n").unwrap(); + fs::write(root.join("pom.xml"), "\n").unwrap(); + fs::create_dir_all(root.join("src/main/java")).unwrap(); + fs::write(root.join("src/main/java/App.java"), "class App {}\n").unwrap(); + fs::write(root.join("Example.sln"), "\n").unwrap(); + fs::write(root.join("Example.csproj"), "\n").unwrap(); + fs::write(root.join("main.cs"), "class App {}\n").unwrap(); + + let repo = root.to_string_lossy(); + + let result = run_one("config", &repo, None); + assert_eq!( + result["python"]["linter"]["settings"]["select"], + serde_json::json!(["E"]), + "{result}" + ); + + assert_eq!(result["python"]["editorconfig"]["indent_size"], "4"); + assert_eq!(result["typescript"]["formatter"]["settings"]["semi"], false); + + assert_eq!( + result["typescript"]["type_checker"]["settings"]["strict"], + true + ); + + assert!( + result["java"]["project_markers"] + .as_array() + .unwrap() + .iter() + .any(|item| item == "src/main/java") + ); + + assert!( + result["csharp"]["project_markers"] + .as_array() + .unwrap() + .iter() + .any(|item| item == "Example.csproj") + ); +} + +#[test] +fn detects_configuration_only_javascript_projects() { + let directory = tempdir().unwrap(); + fs::write(directory.path().join(".prettierrc.yaml"), "semi: false\n").unwrap(); + + let repo = directory.path().to_string_lossy(); + let result = run_one("config", &repo, None); + + assert_eq!(result["typescript"]["formatter"]["name"], "prettier"); + assert_eq!(result["typescript"]["formatter"]["settings"]["semi"], false); +} + +#[test] +fn detects_test_commands_from_makefile_variants() { + let directory = tempdir().unwrap(); + fs::write(directory.path().join("main.rs"), "fn main() {}\n").unwrap(); + fs::write( + directory.path().join("GNUmakefile"), + "test:\n\tcargo test --all\n", + ) + .unwrap(); + + let repo = directory.path().to_string_lossy(); + + let result = run_one("tests", &repo, None); + + assert_eq!(result["rust"]["run_command"], "cargo test --all"); +} + +#[test] +fn preserves_graph_reexports_and_nested_index_resolution() { + let directory = tempdir().unwrap(); + fs::create_dir_all(directory.path().join("src/lib")).unwrap(); + fs::write( + directory.path().join("src/index.ts"), + "export { value } from './lib';\n", + ) + .unwrap(); + fs::write( + directory.path().join("src/lib/index.ts"), + "export const value = 1;\n", + ) + .unwrap(); + + let repo = directory.path().to_string_lossy(); + let result = run_one("graph", &repo, Some("typescript")); + + assert!( + result["typescript"]["edges"] + .as_array() + .unwrap() + .contains(&serde_json::json!({ + "from": "src/index.ts", + "to": "src/lib/index.ts", + "line": 1, + })) + ); +} + +#[test] +fn resolves_swift_module_imports() { + let directory = tempdir().unwrap(); + fs::create_dir_all(directory.path().join("Sources/App")).unwrap(); + fs::create_dir_all(directory.path().join("Sources/Core")).unwrap(); + fs::write( + directory.path().join("Sources/App/App.swift"), + "import Core\npublic struct App {}\n", + ) + .unwrap(); + fs::write( + directory.path().join("Sources/Core/Service.swift"), + "public struct Service {}\n", + ) + .unwrap(); + + let repo = directory.path().to_string_lossy(); + let result = run_one("graph", &repo, Some("swift")); + + assert!( + result["swift"]["edges"] + .as_array() + .unwrap() + .contains(&serde_json::json!({ + "from": "Sources/App/App.swift", + "to": "Sources/Core/Service.swift", + "line": 1, + })) + ); +} + +#[test] +fn detects_package_and_app_monorepo_boundaries() { + let directory = tempdir().unwrap(); + fs::create_dir_all(directory.path().join("packages/one")).unwrap(); + fs::create_dir_all(directory.path().join("packages/two")).unwrap(); + fs::write( + directory.path().join("packages/one/main.rs"), + "fn main() {}\n", + ) + .unwrap(); + fs::write( + directory.path().join("packages/two/main.rs"), + "fn main() {}\n", + ) + .unwrap(); + + let repo = directory.path().to_string_lossy(); + let result = run_one("graph", &repo, None); + + assert_eq!(result["monorepo_boundaries"]["boundary_dir"], "packages"); + assert_eq!( + result["monorepo_boundaries"]["services"], + serde_json::json!(["one", "two"]) + ); +} + +#[test] +fn preserves_language_specific_symbol_categories_and_precision() { + let directory = tempdir().unwrap(); + fs::create_dir_all(directory.path().join("src")).unwrap(); + fs::write( + directory.path().join("src/app.ts"), + "export const VALUE_NAME = 1;\nexport function run() {}\n", + ) + .unwrap(); + fs::write( + directory.path().join("src/main.go"), + "package main\nconst (\n FirstValue = 1\n SecondValue = 2\n)\n", + ) + .unwrap(); + fs::write( + directory.path().join("src/lib.rs"), + "pub struct Parser;\npub enum Status { Ready }\npub trait Store {}\nstatic COUNTER: u64 = 0;\n", + ) + .unwrap(); + fs::write( + directory.path().join("src/main.c"), + "#define MAX_SIZE 10\nint main(void) { return 0; }\n", + ) + .unwrap(); + + let repo = directory.path().to_string_lossy(); + let typescript = run_one("symbols", &repo, Some("typescript")); + let go = run_one("symbols", &repo, Some("go")); + let rust = run_one("symbols", &repo, Some("rust")); + let c = run_one("symbols", &repo, Some("c")); + + assert_eq!(typescript["typescript"]["constants"]["total"], 1); + assert_eq!(go["go"]["constants"]["total"], 2); + assert_eq!(rust["rust"]["traits"]["total"], 1); + assert_eq!(rust["rust"]["statics"]["total"], 1); + assert_eq!(c["c"]["macros"]["total"], 1); +} diff --git a/assets/agentskill.png b/agentskill-assets/agentskill.png similarity index 100% rename from assets/agentskill.png rename to agentskill-assets/agentskill.png diff --git a/agentskill-core/Cargo.toml b/agentskill-core/Cargo.toml new file mode 100644 index 0000000..5540653 --- /dev/null +++ b/agentskill-core/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "agentskill-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +description = "Shared types and repository utilities for agentskill" + +[dependencies] +regex.workspace = true +serde.workspace = true +serde_json.workspace = true +tempfile.workspace = true +thiserror.workspace = true +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/agentskill-core/src/document.rs b/agentskill-core/src/document.rs new file mode 100644 index 0000000..9e4ce7c --- /dev/null +++ b/agentskill-core/src/document.rs @@ -0,0 +1,195 @@ +use std::collections::HashMap; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Section { + pub level: usize, + pub heading: String, + pub body: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Document { + pub preamble: String, + pub sections: Vec
, +} + +pub fn normalize_section_name(name: &str) -> String { + let name = name.trim(); + + let name = name + .split_once('.') + .filter(|(prefix, _)| { + !prefix.is_empty() && prefix.chars().all(|char| char.is_ascii_digit()) + }) + .map_or(name, |(_, value)| value.trim()); + name.split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase() +} + +pub fn parse(text: &str) -> Document { + let mut preamble = String::new(); + + let mut sections = Vec::new(); + let mut current: Option
= None; + + for line in text.lines() { + let Some((level, heading)) = parse_heading(line) else { + if let Some(section) = current.as_mut() { + section.body.push_str(line); + section.body.push('\n'); + } else { + preamble.push_str(line); + preamble.push('\n'); + } + continue; + }; + + if current.is_none() + && level == 1 + && matches!( + normalize_section_name(&heading).as_str(), + "agents" | "agents.md" + ) + { + preamble.push_str(line); + preamble.push('\n'); + continue; + } + + { + if let Some(section) = current.take() { + sections.push(section); + } + current = Some(Section { + level, + heading, + body: String::new(), + }); + } + } + + if let Some(section) = current { + sections.push(section); + } + Document { preamble, sections } +} + +fn parse_heading(line: &str) -> Option<(usize, String)> { + let trimmed = line.trim_start_matches([' ', '\t']); + let indentation = line.len() - trimmed.len(); + + if indentation > 3 || !trimmed.starts_with('#') { + return None; + } + + let level = trimmed.chars().take_while(|char| *char == '#').count(); + if !(1..=6).contains(&level) { + return None; + } + + let rest = &trimmed[level..]; + if !rest.is_empty() && !rest.starts_with([' ', '\t']) { + return None; + } + + Some((level, rest.trim().to_string())) +} + +pub fn serialize(document: &Document) -> String { + let mut output = document.preamble.clone(); + + for section in &document.sections { + if !output.is_empty() && !output.ends_with("\n\n") { + output.push('\n'); + } + output.push_str(&"#".repeat(section.level)); + output.push(' '); + output.push_str(§ion.heading); + output.push('\n'); + + if section.body.is_empty() { + output.push('\n'); + } else { + if !section.body.starts_with('\n') { + output.push('\n'); + } + output.push_str(§ion.body); + + if !output.ends_with('\n') { + output.push('\n'); + } + } + + if !output.ends_with("\n\n") { + output.push('\n'); + } + } + output +} + +pub fn merge( + existing: &str, + generated: &Document, + only: &[String], + exclude: &[String], + force: bool, +) -> String { + if force { + return serialize(generated); + } + + let requested: Option> = if only.is_empty() { + None + } else { + Some(only.iter().map(|x| normalize_section_name(x)).collect()) + }; + + let excluded: std::collections::HashSet<_> = + exclude.iter().map(|x| normalize_section_name(x)).collect(); + + let mut document = parse(existing); + let generated_map: HashMap<_, _> = generated + .sections + .iter() + .map(|section| (normalize_section_name(§ion.heading), section)) + .collect(); + + for section in &mut document.sections { + let key = normalize_section_name(§ion.heading); + + if requested + .as_ref() + .is_some_and(|items| !items.contains(&key)) + || excluded.contains(&key) + { + continue; + } + + if let Some(new_section) = generated_map.get(&key) { + *section = (*new_section).clone(); + } + } + + for section in &generated.sections { + let key = normalize_section_name(§ion.heading); + + if requested + .as_ref() + .is_some_and(|items| !items.contains(&key)) + || excluded.contains(&key) + { + continue; + } + + if !document + .sections + .iter() + .any(|item| normalize_section_name(&item.heading) == key) + { + document.sections.push(section.clone()); + } + } + serialize(&document) +} diff --git a/agentskill-core/src/error.rs b/agentskill-core/src/error.rs new file mode 100644 index 0000000..9353305 --- /dev/null +++ b/agentskill-core/src/error.rs @@ -0,0 +1,64 @@ +use std::fmt; + +use serde::Serialize; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub enum AgentskillError { + Io(std::io::Error), + InvalidPath(String), + InvalidArgument(String), + Json(serde_json::Error), + Other(String), +} + +impl fmt::Display for AgentskillError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(f, "{error}"), + Self::InvalidPath(message) | Self::InvalidArgument(message) | Self::Other(message) => { + f.write_str(message) + } + Self::Json(error) => write!(f, "{error}"), + } + } +} + +impl std::error::Error for AgentskillError {} + +impl From for AgentskillError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for AgentskillError { + fn from(value: serde_json::Error) -> Self { + Self::Json(value) + } +} + +#[derive(Debug, Serialize)] +pub struct ErrorPayload { + pub error: String, + pub script: String, +} + +pub fn error_payload(error: impl ToString, script: &str) -> serde_json::Value { + serde_json::json!({"error": error.to_string(), "script": script}) +} + +pub fn validate_repo(path: &str) -> Result { + let repo = std::path::Path::new(path) + .canonicalize() + .map_err(|_| AgentskillError::InvalidPath(format!("path does not exist: {path}")))?; + + if !repo.is_dir() { + return Err(AgentskillError::InvalidPath(format!( + "not a directory: {path}" + ))); + } + + Ok(repo) +} diff --git a/agentskill-core/src/fs.rs b/agentskill-core/src/fs.rs new file mode 100644 index 0000000..befbb8a --- /dev/null +++ b/agentskill-core/src/fs.rs @@ -0,0 +1,149 @@ +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use crate::language::{LanguageSpec, language_by_id, language_for_path}; + +const SKIP_DIRS: &[&str] = &[ + ".git", + ".hg", + ".svn", + "node_modules", + "vendor", + "third_party", + ".tox", + ".nox", + ".eggs", + "site-packages", + "htmlcov", + "coverage", + "out", + "target", + ".venv", + "venv", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "dist", + "build", + ".agentskill", +]; + +const MAX_FILES_TO_PARSE: usize = 10_000; +const MAX_FILE_BYTES: usize = 1_000_000; + +#[derive(Clone, Debug)] +pub struct RepoFile { + pub path: PathBuf, + pub relative: String, + pub language: Option<&'static LanguageSpec>, + pub bytes: u64, + pub lines: usize, +} + +pub fn read_text(path: &Path) -> String { + fs::File::open(path) + .and_then(|file| { + let mut bytes = Vec::new(); + file.take(MAX_FILE_BYTES as u64).read_to_end(&mut bytes)?; + Ok(bytes) + }) + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) + .unwrap_or_default() +} + +pub fn collect_files(repo: &Path) -> Vec { + let mut files = Vec::new(); + collect_into(repo, repo, &mut files); + files.sort_by(|left, right| left.relative.cmp(&right.relative)); + files +} + +fn collect_into(repo: &Path, current: &Path, files: &mut Vec) { + if files.len() >= MAX_FILES_TO_PARSE { + return; + } + + let Ok(entries) = fs::read_dir(current) else { + return; + }; + + let mut entries = entries.flatten().collect::>(); + entries.sort_by_key(|entry| entry.file_name()); + + for entry in entries { + if files.len() >= MAX_FILES_TO_PARSE { + return; + } + + let path = entry.path(); + + let name = entry.file_name().to_string_lossy().into_owned(); + if path.is_symlink() { + continue; + } + + if path.is_dir() { + if !SKIP_DIRS.contains(&name.as_str()) && !name.starts_with('.') { + collect_into(repo, &path, files); + } + continue; + } + + if !path.is_file() { + continue; + } + + let Ok(metadata) = fs::metadata(&path) else { + continue; + }; + + let relative = path + .strip_prefix(repo) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + + let text = read_text(&path); + let language = if path.extension().and_then(|value| value.to_str()) == Some("h") + && text.contains("@interface") + { + language_by_id("objectivec") + } else { + language_for_path(Path::new(&name)) + }; + let lines = line_count(&path); + files.push(RepoFile { + path, + relative, + language, + bytes: metadata.len(), + lines, + }); + } +} + +pub fn line_count(path: &Path) -> usize { + let Ok(mut file) = fs::File::open(path) else { + return 0; + }; + + let mut count = 0; + let mut buffer = [0; 65_536]; + + loop { + let Ok(bytes) = std::io::Read::read(&mut file, &mut buffer) else { + return count; + }; + + if bytes == 0 { + return count; + } + + count += buffer[..bytes] + .iter() + .filter(|byte| **byte == b'\n') + .count(); + } +} diff --git a/agentskill-core/src/language.rs b/agentskill-core/src/language.rs new file mode 100644 index 0000000..8c494ad --- /dev/null +++ b/agentskill-core/src/language.rs @@ -0,0 +1,162 @@ +use std::io::BufRead; +use std::path::Path; + +use serde::Serialize; + +#[derive(Clone, Copy, Debug, Serialize)] +pub struct LanguageSpec { + pub id: &'static str, + pub display_name: &'static str, + pub extensions: &'static [&'static str], + pub test_patterns: &'static [&'static str], +} + +pub const LANGUAGES: &[LanguageSpec] = &[ + LanguageSpec { + id: "python", + display_name: "Python", + extensions: &[".py"], + test_patterns: &["test_", "_test.py"], + }, + LanguageSpec { + id: "typescript", + display_name: "TypeScript", + extensions: &[".ts", ".tsx"], + test_patterns: &[".test.", ".spec."], + }, + LanguageSpec { + id: "javascript", + display_name: "JavaScript", + extensions: &[".js", ".jsx", ".mjs", ".cjs"], + test_patterns: &[".test.", ".spec."], + }, + LanguageSpec { + id: "go", + display_name: "Go", + extensions: &[".go"], + test_patterns: &["_test.go"], + }, + LanguageSpec { + id: "rust", + display_name: "Rust", + extensions: &[".rs"], + test_patterns: &["_test.rs"], + }, + LanguageSpec { + id: "java", + display_name: "Java", + extensions: &[".java"], + test_patterns: &["Test.java", "Tests.java"], + }, + LanguageSpec { + id: "kotlin", + display_name: "Kotlin", + extensions: &[".kt", ".kts"], + test_patterns: &["Test.kt", "Tests.kt"], + }, + LanguageSpec { + id: "csharp", + display_name: "C#", + extensions: &[".cs"], + test_patterns: &["Test.cs", "Tests.cs"], + }, + LanguageSpec { + id: "c", + display_name: "C", + extensions: &[".c", ".h"], + test_patterns: &["_test.c", "_tests.c"], + }, + LanguageSpec { + id: "cpp", + display_name: "C++", + extensions: &[".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"], + test_patterns: &["_test.", "_tests."], + }, + LanguageSpec { + id: "ruby", + display_name: "Ruby", + extensions: &[".rb"], + test_patterns: &["_spec.rb", "test_"], + }, + LanguageSpec { + id: "php", + display_name: "PHP", + extensions: &[".php"], + test_patterns: &["Test.php"], + }, + LanguageSpec { + id: "swift", + display_name: "Swift", + extensions: &[".swift"], + test_patterns: &["Tests.swift"], + }, + LanguageSpec { + id: "objectivec", + display_name: "Objective-C", + extensions: &[".m", ".mm"], + test_patterns: &["Tests.m", "Tests.mm"], + }, + LanguageSpec { + id: "bash", + display_name: "Bash", + extensions: &[".sh", ".bash"], + test_patterns: &["test_", "_test.sh", ".bats"], + }, +]; + +pub fn language_by_id(id: &str) -> Option<&'static LanguageSpec> { + LANGUAGES.iter().find(|language| language.id == id) +} + +pub fn language_for_path(path: &Path) -> Option<&'static LanguageSpec> { + if let Some(extension) = path.extension().and_then(|value| value.to_str()) { + let extension = format!(".{}", extension.to_ascii_lowercase()); + + if let Some(language) = LANGUAGES + .iter() + .find(|language| language.extensions.iter().any(|item| *item == extension)) + { + return Some(language); + } + } + + let file = std::fs::File::open(path).ok()?; + let mut first_line = String::new(); + std::io::BufReader::new(file) + .read_line(&mut first_line) + .ok() + .filter(|bytes| *bytes > 0)?; + + let first_line = first_line.trim(); + (first_line.starts_with("#!") + && (first_line.contains("/bash") + || first_line.contains("/sh") + || first_line.ends_with("bash") + || first_line.ends_with("sh"))) + .then(|| language_by_id("bash")) + .flatten() +} + +pub fn is_test_path(path: &Path, language: &LanguageSpec) -> bool { + let text = path.to_string_lossy(); + + let file = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + let named_match = language + .test_patterns + .iter() + .any(|pattern| file.contains(pattern) || text.contains(pattern)); + + if named_match { + return true; + } + + path.components().any(|component| { + matches!( + component.as_os_str().to_str(), + Some("test" | "tests" | "__tests__" | "spec" | "specs") + ) + }) +} diff --git a/agentskill-core/src/lib.rs b/agentskill-core/src/lib.rs new file mode 100644 index 0000000..64f0b60 --- /dev/null +++ b/agentskill-core/src/lib.rs @@ -0,0 +1,10 @@ +//! Shared domain types and tolerant repository utilities. + +pub mod document; +pub mod error; +pub mod fs; +pub mod language; +pub mod output; +pub mod reference; + +pub use error::{AgentskillError, Result}; diff --git a/agentskill-core/src/output.rs b/agentskill-core/src/output.rs new file mode 100644 index 0000000..69cc3a9 --- /dev/null +++ b/agentskill-core/src/output.rs @@ -0,0 +1,70 @@ +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::error::{AgentskillError, Result}; + +pub const ANALYZER_NAMES: &[&str] = &[ + "scan", "measure", "config", "git", "graph", "symbols", "tests", +]; + +pub fn pretty_json(value: &Value, pretty: bool) -> String { + if pretty { + serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string()) + } else { + serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) + } +} + +pub fn validate_out_path(out: &str) -> Result { + let path = Path::new(out); + + if path.is_absolute() { + return Err(AgentskillError::InvalidArgument(format!( + "invalid output path: absolute paths are not allowed: {out}" + ))); + } + + let mut relative = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::Normal(value) => relative.push(value), + std::path::Component::ParentDir => { + if !relative.pop() { + return Err(AgentskillError::InvalidArgument(format!( + "invalid output path: escaping the working directory is not allowed: {out}" + ))); + } + } + std::path::Component::RootDir | std::path::Component::Prefix(_) => { + return Err(AgentskillError::InvalidArgument(format!( + "invalid output path: absolute paths are not allowed: {out}" + ))); + } + } + } + + Ok(std::env::current_dir()?.join(relative)) +} + +pub fn write_value(value: &Value, pretty: bool, out: Option<&str>) -> Result<()> { + let text = pretty_json(value, pretty) + "\n"; + + match out { + Some(path) => { + let path = validate_out_path(path)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, text)?; + + Ok(()) + } + None => { + print!("{text}"); + + Ok(()) + } + } +} diff --git a/agentskill-core/src/reference.rs b/agentskill-core/src/reference.rs new file mode 100644 index 0000000..fb6c7af --- /dev/null +++ b/agentskill-core/src/reference.rs @@ -0,0 +1,182 @@ +use std::collections::HashSet; +use std::path::Path; +use std::process::Command; +use std::time::{Duration, Instant}; + +use crate::error::{AgentskillError, Result}; +use serde::Serialize; + +const REMOTE_REFERENCE_TIMEOUT: Duration = Duration::from_secs(60); + +#[derive(Clone, Debug, Serialize)] +pub struct ReferenceSource { + pub kind: String, + pub value: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ReferenceDocument { + pub source: ReferenceSource, + pub content: String, + pub source_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub commit_sha: Option, +} + +pub fn validate_references(references: &[String]) -> Result<()> { + let mut seen = HashSet::new(); + + for reference in references { + let is_remote = reference.starts_with("http://") + || reference.starts_with("https://") + || reference.starts_with("ssh://") + || reference.starts_with("git@"); + + let identity = if is_remote { + reference.clone() + } else { + let path = Path::new(reference).canonicalize().map_err(|_| { + AgentskillError::InvalidPath(format!("reference path does not exist: {reference}")) + })?; + path.to_string_lossy().into_owned() + }; + + if !seen.insert(identity) { + return Err(AgentskillError::InvalidArgument(format!( + "duplicate reference source: {reference}" + ))); + } + + if !is_remote { + let root = Path::new(reference); + + if !root.is_dir() { + return Err(AgentskillError::InvalidPath(format!( + "reference path is not a directory: {reference}" + ))); + } + + let document = root.join("AGENTS.md"); + + if !document.is_file() { + return Err(AgentskillError::InvalidPath(format!( + "AGENTS.md not found in reference repository: {reference}" + ))); + } + + if std::fs::read_to_string(document) + .map(|text| text.trim().is_empty()) + .unwrap_or(true) + { + return Err(AgentskillError::InvalidPath(format!( + "AGENTS.md is empty in reference repository: {reference}" + ))); + } + } + } + + Ok(()) +} + +pub fn load_reference_documents(references: &[String]) -> Result> { + validate_references(references)?; + references + .iter() + .map(|reference| { + if is_remote(reference) { + load_remote_reference(reference) + } else { + let path = Path::new(reference).join("AGENTS.md"); + + let content = std::fs::read_to_string(&path).map_err(|_| { + AgentskillError::InvalidPath(format!( + "AGENTS.md not found in reference repository: {reference}" + )) + })?; + + Ok(ReferenceDocument { + source: ReferenceSource { + kind: "local".into(), + value: reference.clone(), + }, + content, + source_path: "AGENTS.md".into(), + commit_sha: None, + }) + } + }) + .collect() +} + +fn is_remote(reference: &str) -> bool { + ["http://", "https://", "ssh://", "git@"] + .iter() + .any(|prefix| reference.starts_with(prefix)) +} + +fn load_remote_reference(reference: &str) -> Result { + let directory = tempfile::tempdir()?; + + let checkout = directory.path().join("reference"); + let mut child = Command::new("git") + .args(["clone", "--depth", "1", reference]) + .arg(&checkout) + .spawn() + .map_err(|error| { + AgentskillError::InvalidPath(format!( + "failed to clone remote reference repository: {reference}: {error}" + )) + })?; + + let started = Instant::now(); + loop { + if child.try_wait()?.is_some() { + break; + } + + if started.elapsed() >= REMOTE_REFERENCE_TIMEOUT { + let _ = child.kill(); + + let _ = child.wait(); + return Err(AgentskillError::InvalidPath(format!( + "failed to clone remote reference repository: {reference}" + ))); + } + std::thread::sleep(Duration::from_millis(50)); + } + + let status = child.wait_with_output()?; + + if !status.status.success() { + return Err(AgentskillError::InvalidPath(format!( + "failed to clone remote reference repository: {reference}" + ))); + } + + let path = checkout.join("AGENTS.md"); + + let content = std::fs::read_to_string(&path).map_err(|_| { + AgentskillError::InvalidPath(format!( + "AGENTS.md not found in remote reference repository: {reference}" + )) + })?; + + let sha = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&checkout) + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|value| !value.is_empty()); + + Ok(ReferenceDocument { + source: ReferenceSource { + kind: "remote".into(), + value: reference.into(), + }, + content, + source_path: "AGENTS.md".into(), + commit_sha: sha, + }) +} diff --git a/agentskill-core/tests/core.rs b/agentskill-core/tests/core.rs new file mode 100644 index 0000000..878911b --- /dev/null +++ b/agentskill-core/tests/core.rs @@ -0,0 +1,207 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use agentskill_core::document::{Document, Section, merge, parse, serialize}; +use agentskill_core::error::{AgentskillError, error_payload, validate_repo}; +use agentskill_core::fs::{collect_files, line_count, read_text}; +use agentskill_core::language::{LANGUAGES, is_test_path, language_by_id, language_for_path}; +use agentskill_core::output::{pretty_json, validate_out_path, write_value}; +use agentskill_core::reference::validate_references; +use serde_json::json; +use tempfile::tempdir; + +#[test] +fn preserves_supported_language_matrix() { + assert_eq!(LANGUAGES.len(), 15); + + assert_eq!( + language_by_id("python").map(|item| item.display_name), + Some("Python") + ); + + assert_eq!( + language_for_path(Path::new("src/main.rs")).map(|item| item.id), + Some("rust") + ); + + assert_eq!( + language_for_path(Path::new("src/index.tsx")).map(|item| item.id), + Some("typescript") + ); +} + +#[test] +fn parses_and_merges_sectioned_documents() { + let generated = Document { + preamble: "# AGENTS.md\n\n".into(), + sections: vec![Section { + level: 2, + heading: "Testing".into(), + body: "Run cargo test.\n".into(), + }], + }; + + let existing = + "# AGENTS.md\n\nManual preamble.\n\n## Testing\n\nOld rule.\n\n## Custom\n\nKeep me.\n"; + + let merged = merge(existing, &generated, &[], &[], false); + let document = parse(&merged); + + assert!( + document + .sections + .iter() + .any(|section| section.heading == "Testing") + ); + + assert!(merged.contains("Run cargo test.")); + assert!(merged.contains("## Testing\n\nRun cargo test.")); + + assert!(merged.contains("## Custom")); + assert!(serialize(&document).ends_with('\n')); +} + +#[test] +fn treats_agents_title_as_preamble_and_preserves_custom_markdown() { + let document = + parse("# AGENTS.md\n\n## Custom\n\nKeep this rule.\n\n### Detail\n\nMore context.\n"); + + assert_eq!(document.preamble, "# AGENTS.md\n\n"); + assert_eq!(document.sections.len(), 2); + assert_eq!(document.sections[0].heading, "Custom"); + assert_eq!( + serialize(&document), + "# AGENTS.md\n\n## Custom\n\nKeep this rule.\n\n### Detail\n\nMore context.\n\n" + ); +} + +#[test] +fn validates_paths_reads_files_and_skips_links() { + let directory = tempdir().unwrap(); + + let source = directory.path().join("main.rs"); + fs::write(&source, "fn main() {}\n").unwrap(); + fs::create_dir(directory.path().join(".hidden")).unwrap(); + fs::write( + directory.path().join(".hidden/ignored.rs"), + "fn ignored() {}\n", + ) + .unwrap(); + + assert_eq!( + validate_repo(directory.path().to_str().unwrap()).unwrap(), + directory.path().canonicalize().unwrap() + ); + + assert_eq!(read_text(&source), "fn main() {}\n"); + assert_eq!(line_count(&source), 1); + + assert_eq!(read_text(&directory.path().join("missing")), ""); + assert_eq!(line_count(&directory.path().join("missing")), 0); + + assert_eq!(collect_files(directory.path()).len(), 1); + + let file_path = source.to_string_lossy().into_owned(); + + let error = validate_repo(&file_path).unwrap_err(); + assert!(matches!(error, AgentskillError::InvalidPath(_))); + + #[cfg(unix)] + { + std::os::unix::fs::symlink(&source, directory.path().join("linked.rs")).unwrap(); + + assert_eq!(collect_files(directory.path()).len(), 1); + } +} + +#[test] +fn preserves_byte_limited_reads_and_newline_based_line_counts() { + let directory = tempdir().unwrap(); + let source = directory.path().join("script"); + fs::write(&source, "#!/usr/bin/env bash\necho ok").unwrap(); + + assert_eq!(language_for_path(&source).map(|item| item.id), Some("bash")); + assert_eq!(line_count(&source), 1); + + let rust = directory.path().join("main.rs"); + fs::write(&rust, "fn main() {}").unwrap(); + assert_eq!(line_count(&rust), 0); +} + +#[test] +fn detects_test_directories_for_all_registered_languages() { + let python = language_by_id("python").unwrap(); + let typescript = language_by_id("typescript").unwrap(); + + assert!(is_test_path(Path::new("tests/unit/app.py"), python)); + assert!(is_test_path(Path::new("src/__tests__/app.ts"), typescript)); + assert!(is_test_path( + Path::new("src/test/java/App.java"), + language_by_id("java").unwrap() + )); +} + +#[test] +fn validates_references_and_serializes_output() { + let reference = tempdir().unwrap(); + fs::write(reference.path().join("AGENTS.md"), "# AGENTS.md\n").unwrap(); + + let reference_path = reference.path().to_string_lossy().into_owned(); + + validate_references(std::slice::from_ref(&reference_path)).unwrap(); + validate_references(&["https://example.com/agentskill.git".into()]).unwrap(); + + let duplicate = validate_references(&[reference_path.clone(), reference_path]); + + assert!(matches!( + duplicate, + Err(AgentskillError::InvalidArgument(_)) + )); + + let missing = validate_references(&["/missing/reference".into()]); + assert!(matches!(missing, Err(AgentskillError::InvalidPath(_)))); + + let empty = tempdir().unwrap(); + fs::write(empty.path().join("AGENTS.md"), "\n").unwrap(); + + let empty_path = empty.path().to_string_lossy().into_owned(); + assert!(validate_references(&[empty_path]).is_err()); + + let value = json!({"ok": true}); + + assert!(pretty_json(&value, true).contains("\n")); + let output = format!("agentskill-output-{}.json", std::process::id()); + write_value(&value, false, Some(&output)).unwrap(); + + assert_eq!(fs::read_to_string(&output).unwrap(), "{\"ok\":true}\n"); + fs::remove_file(output).unwrap(); + assert_eq!(error_payload("bad", "scan")["script"], "scan"); +} + +#[test] +fn validates_and_writes_safe_output_paths() { + let absolute = std::env::current_dir().unwrap().join("report.json"); + + assert_eq!( + validate_out_path(absolute.to_str().unwrap()) + .unwrap_err() + .to_string(), + format!( + "invalid output path: absolute paths are not allowed: {}", + absolute.display() + ) + ); + + assert!(validate_out_path("../report.json").is_err()); + + let output = PathBuf::from(format!("agentskill-output-{}", std::process::id())); + let relative = output.join("nested/report.json"); + write_value( + &serde_json::json!({"ok": true}), + false, + Some(relative.to_str().unwrap()), + ) + .unwrap(); + assert!(relative.exists()); + std::fs::remove_dir_all(output).unwrap(); +} diff --git a/agentskill-docs/README.md b/agentskill-docs/README.md new file mode 100644 index 0000000..3dff613 --- /dev/null +++ b/agentskill-docs/README.md @@ -0,0 +1,11 @@ +# Agentskill Documentation + +This directory contains user-facing product and command documentation for the +Rust agentskill workspace. + +- [`cli.md`](./cli.md) documents the executable command surface. +- [`architecture.md`](./architecture.md) documents crate ownership, data flow, + analyzer contracts, generation semantics, CI, releases, and extension paths. + +The installed command's `--help` output remains the authoritative flag +reference. diff --git a/agentskill-docs/architecture.md b/agentskill-docs/architecture.md new file mode 100644 index 0000000..97d3235 --- /dev/null +++ b/agentskill-docs/architecture.md @@ -0,0 +1,654 @@ +# Architecture + +This document describes the technical architecture of `agentskill` v2. It is +the implementation map for contributors who need to understand where evidence +comes from, how it moves through the system, how markdown is produced, and how +the release automation turns a Git tag into portable binaries. + +## System Boundary + +`agentskill` analyzes a target repository. The target repository is data: it is +walked, read, and inspected, but it is not compiled or modified during an +analyzer run. Generation and update are the two intentional write workflows. + +The project itself is a Rust workspace. The repositories it analyzes can use +the supported target-language matrix: + +```text +Python · TypeScript · JavaScript · Go · Rust · Java · Kotlin · C# · C +C++ · Ruby · PHP · Swift · Objective-C · Bash +``` + +The implementation has four runtime crates and four repository-support areas: + +```text +Workspace Runtime +├── agentskill-core +│ └── shared domain types, filesystem access, language registry, +│ documents, references, errors, and JSON output +├── agentskill-analyzers +│ └── scan, measure, config, git, graph, symbols, tests, and aggregation +├── agentskill-generation +│ └── AGENTS.md rendering, profiles, layouts, references, feedback, and merge +└── agentskill + └── Clap command parsing and the agentskill/agsk binaries + +Repository Support +├── agentskill-docs +│ └── user-facing CLI and architecture documentation +├── agentskill-scripts +│ └── pre-commit, release-note, and release-archive helpers +├── agentskill-skill +│ └── packaged skill instructions, synthesis contract, references, examples +└── agentskill-tests + └── compatibility contract fixtures and configuration fixtures +``` + +The dependency direction is deliberately one-way: + +```mermaid +flowchart BT + core[agentskill-core] + analyzers[agentskill-analyzers] + generation[agentskill-generation] + cli[agentskill] + + core --> analyzers + analyzers --> generation + generation --> cli +``` + +`agentskill-core` does not depend on analyzer or generation code. This keeps +the shared data and safety rules reusable and makes each layer independently +testable. + +## Crate Responsibilities + +### `agentskill-core` + +The core crate owns behavior that must be consistent across every analyzer and +generation flow. + +| Module | Responsibility | +| --- | --- | +| `error` | `AgentskillError`, `Result`, path validation, and public error payloads | +| `fs` | bounded text reads, deterministic repository walking, file metadata, and line counts | +| `language` | supported-language registry, extension/shebang detection, and test-path detection | +| `document` | markdown heading parsing, section normalization, serialization, and merge semantics | +| `reference` | local/remote reference validation, loading, and commit metadata | +| `output` | compact/pretty JSON formatting, safe output paths, and file/stdout writing | +| `lib` | public module exports | + +The core layer is intentionally tolerant at repository boundaries. Unreadable +files are skipped or represented as empty text where continuing the scan gives +more useful output than aborting. Invalid user arguments and invalid repository +paths remain explicit errors. + +### `agentskill-analyzers` + +The analyzer crate converts a repository into structured evidence. Every public +analyzer has the same broad contract: + +```rust +pub fn run(repo: &str, options: ...) -> agentskill_core::Result +``` + +The CLI boundary converts an error into the stable shape below: + +```json +{ + "error": "not a directory: ./missing", + "script": "scan" +} +``` + +The modules are intentionally data-oriented instead of sharing a large class +hierarchy. Each analyzer reads the common `RepoFile` representation and emits +the JSON structure best suited to its evidence. + +| Analyzer | Evidence Produced | +| --- | --- | +| `scan` | file tree, source-file inventory, language totals, entrypoint/read-order signals | +| `measure` | indentation, line-length distributions, blank-line patterns, trailing whitespace, newline presence | +| `config` | formatter, linter, type-checker, project-marker, editorconfig, and tool settings | +| `git` | commit subjects, conventional prefixes, branches, merge signals, and repository history | +| `graph` | internal import edges, module resolution, cycles, dependency concentration, and monorepo boundaries | +| `symbols` | functions, classes/types, constants, language-specific categories, naming patterns, and precision summaries | +| `tests` | framework detection, test/source counts, test naming, mappings, fixtures, and run commands | + +The aggregate runner exposes two levels: + +```mermaid +flowchart TD + one[run_one
dispatch one analyzer
normalize failure payload] + all[run_all
run registered analyzers in parallel with Rayon] + many[run_many
accept one or more repositories] + direct[Aggregate object] + keyed[Repository-keyed object] + + many -->|one repository| all + many -->|multiple repositories| keyed + all --> one + one --> direct +``` + +The analyzer registry lives in `agentskill-core::output::ANALYZER_NAMES` and is +used by aggregation and contract tests. Adding a public analyzer requires +updating the registry, dispatch, documentation, and tests together. + +### `agentskill-generation` + +The generation crate consumes one aggregate analysis value and turns it into a +sectioned `Document`. It owns no analyzer-specific parsing; it reads facts from +the aggregate JSON and renders them into stable, title-cased sections. + +The generation pipeline is: + +```mermaid +flowchart TD + validate[Validate repository, profile, and layout] + inputs[Load references and feedback] + analyze[run_all(repository)] + render[Render ordered sections] + enrich[Apply interactive answers
Apply feedback notes
Attach reference metadata] + output[Serialize or merge markdown] + write[Write output files or print stdout] + + validate --> inputs --> analyze --> render --> enrich --> output --> write +``` + +Generation and update have different semantics: + +| Flow | Existing `AGENTS.md` | Custom Sections | Writes By Default | +| --- | --- | --- | --- | +| `generate` | ignored | not merged | stdout | +| `update` | used as merge input | preserved unless forced/filtered | repository `AGENTS.md` | + +`update` uses normalized section names. Number prefixes, case, and repeated +whitespace do not affect matching, so `Testing`, `12. Testing`, and +`## 12. Testing` identify the same logical section. + +### `agentskill` + +The application crate is intentionally thin. It contains: + +1. Clap structs describing the public command surface. +2. Dispatch from parsed commands to library functions. +3. JSON output for analyzers. +4. Exit-code conversion at the process boundary. +5. The two binary targets, `agentskill` and `agsk`. + +Analyzer implementation does not belong in this crate. This keeps direct Rust +callers and tests independent of command-line parsing. + +## Command Data Flow + +### Analyzer Commands + +The single-analyzer path is: + +```mermaid +flowchart TD + argv[argv] --> clap[Clap parser] + clap --> dispatch[agentskill::write_analyzer] + dispatch --> run[analyzers::run_one] + run --> validate[Validate repository] + validate --> collect[Collect bounded, sorted RepoFile values] + collect --> evidence[Analyze selected evidence] + evidence --> write[output::write_value] + write --> format[Compact or pretty JSON] + format --> destination{--out supplied?} + destination -->|yes| file[Safe relative output file] + destination -->|no| stdout[stdout] + write --> status[Exit code 0 or 1 from payload] +``` + +`analyze` calls `run_many` and preserves the aggregate object shape. It also +validates every requested reference before analysis. Single-analyzer errors +are emitted as JSON and return a failed process status, allowing both humans +and automation to inspect the failure without parsing stderr. + +### Repository Walking + +Repository traversal is centralized in `agentskill-core::fs`: + +```mermaid +flowchart TD + root[Repository root] + skip[Skip generated, dependency, cache, hidden, and symlinked paths] + sort[Sort directory entries] + cap[Cap parsed files] + detect[Detect extension or shell shebang] + read[Read text up to one megabyte] + finish[Sort final relative paths] + + root --> skip --> sort --> cap --> detect --> read --> finish +``` + +The walker records both the physical byte size and newline-based line count. +Text analyzers use bounded lossy UTF-8 reads, while binary-like or unsupported +files remain in the inventory only when they can be classified meaningfully. + +This centralization prevents analyzers from disagreeing about hidden folders, +file ordering, limits, and language detection. + +### Language Detection + +The language registry is the single source of truth for: + +- language identifiers and display names; +- recognized extensions; +- extensionless Bash/shebang detection; +- language-specific test filename patterns; and +- test-directory names such as `tests`, `spec`, and `__tests__`. + +Case-insensitive extension matching is used so `MAIN.RS` and `main.rs` follow +the same path. Objective-C headers receive a content-aware distinction when a +header contains an `@interface` declaration. + +## Analyzer Internals + +### Scan + +`scan` aggregates `RepoFile` values by language and reports a deterministic +tree. Entrypoints receive priority in read-order evidence, followed by line +count and path ordering. Unsupported files do not become false language +signals. + +### Measure + +`measure` operates on source text without rewriting it. It records: + +- tab and space indentation evidence; +- mixed-indentation files; +- p50, p75, p95, p99, and maximum nonblank line lengths; +- language-specific blank-line distributions; +- missing or present final newlines; and +- files containing trailing whitespace. + +Empty populations are represented with stable empty structures rather than +fabricated measurements. + +### Config + +`config` detects configuration from bounded TOML, YAML, JSON, INI, and +EditorConfig-like files. It keeps configuration-only projects visible even +when they contain no recognized source file. Nested project markers are found +recursively, while public marker values remain relative basenames for contract +compatibility. + +### Git + +`git` is the only analyzer that invokes an external repository command. It +degrades to structured error information when history is unavailable, while +preserving successful commit, branch, prefix, and merge evidence. + +### Graph + +`graph` builds a language-aware module index and resolves internal edges only. +External package names are ignored. The resolver handles relative imports, +index modules, JavaScript/TypeScript re-exports, Go module prefixes, Swift +module roots, and monorepo boundary directories. + +Graph output is bounded: + +```text +internal edges ≤ 200 +reported cycles ≤ 20 +most-depended ≤ 10 +``` + +Cycles are emitted as closed paths with the starting module repeated. This is +useful to callers because the final edge is explicit instead of implied. + +### Symbols + +`symbols` strips comments before extracting declarations. It preserves C/C++ +preprocessor directives, recognizes language-specific declarations, and keeps +file names as normalized stems. Pattern summaries are deterministic and +percentages are rounded to one decimal place. + +### Tests + +`tests` divides files into source and test sets using the shared language +registry, detects framework signals, infers commands from Makefiles and +package manifests, and maps test stems back to source stems. It reports +unmatched tests and untested sources rather than hiding mapping gaps. + +## Document Model And Merge Semantics + +Markdown is represented as: + +```rust +Document { + preamble: String, + sections: Vec
, +} + +Section { + level: usize, + heading: String, + body: String, +} +``` + +The parser recognizes ATX headings with up to three leading spaces, supports +heading levels one through six, and treats an initial `# AGENTS.md` or +`# Agents` line as preamble rather than a generated section. + +The merge algorithm is ordered and conservative: + +```mermaid +flowchart TD + force{force = true?} + serialize[Serialize generated document only] + parse[Parse existing document] + replace[Replace matching generated sections] + preserve[Preserve excluded or non-requested sections] + append[Append newly generated sections in generated order] + custom[Preserve unknown custom sections] + done[Write merged document] + + force -->|yes| serialize --> done + force -->|no| parse --> replace --> preserve --> append --> custom --> done +``` + +`--section` limits replacement to named sections. `--exclude-section` prevents +replacement and appending for named sections. Feedback-preserved sections are +added to the effective exclusion set during normal update mode and ignored by +forced rebuilds. + +## Profiles And Layouts + +Profiles control information density: + +| Profile | Meaning | +| --- | --- | +| `concise` | compact operational guidance for normal agent context | +| `comprehensive` | the same evidence with expanded verification guidance | + +Layouts control packaging, not facts: + +| Layout | Output | +| --- | --- | +| `single` | one `AGENTS.md` document, or stdout when no `--out` is supplied | +| `split` | concise primary document plus `AGENTS.reference.md` | +| `multifile` | root index plus numbered files under `.agentskill/` | + +The same aggregate analysis feeds every profile and layout. This is important: +changing presentation must not silently change the repository facts used to +write guidance. + +`update` currently supports the `single` layout because section merge semantics +are defined for one canonical document. Unsupported combinations fail with a +targeted argument error instead of silently producing a different layout. + +## References And Interactive Generation + +References can be local repository paths or Git URLs. Local references require +a nonempty `AGENTS.md`. Remote references are shallow-cloned into a temporary +directory, have a bounded clone wait, and record the resolved commit SHA when +available. + +Reference metadata is embedded in generated output as a machine-readable HTML +comment: + +```text + +``` + +References are validated for duplicate identity before analysis. Local paths +are canonicalized for identity; remote URLs use their supplied identity. + +Interactive generation first uses detected evidence. When a canonical test +command or Git convention is missing, it can infer a value from reference +markdown or ask the operator. Answers are inserted as explicit notes in the +affected generated sections, so inferred or human-supplied information remains +visible rather than becoming hidden state. + +## Feedback Sidecar + +Update reads an optional repository-local `.agentskill-feedback.json` file. +Its supported shape is: + +```json +{ + "preserve_sections": ["Git"], + "sections": { + "Testing": { + "prepend_notes": ["Keep integration tests fast."], + "pinned_facts": ["The test command is cargo test."] + } + } +} +``` + +The loader validates object and list shapes, normalizes section names, rejects +duplicate names after normalization, and returns actionable errors. Notes and +pinned facts are rendered before regenerated section content. Preserved +sections affect normal update mode but do not survive `--force`. + +## CLI And Process Contracts + +Both binaries are equivalent: + +```text +agentskill ... +agsk ... +``` + +The public analyzer commands are: + +```text +analyze scan measure config git graph symbols tests +``` + +The document commands are: + +```text +generate update +``` + +Analyzer commands support structured JSON output, `--pretty`, and safe +relative `--out FILE` paths. Document commands produce markdown and reject +`--pretty` because pretty JSON has no meaning for markdown. + +The process boundary follows these rules: + +| Condition | Output | Exit Status | +| --- | --- | --- | +| successful analyzer | JSON result | `0` | +| analyzer-level failure | `{ "error": ..., "script": ... }` JSON | `1` | +| invalid document argument or write failure | diagnostic on stderr | `1` | +| successful generation/update | markdown file or stdout | `0` | + +## CI And Release Architecture + +### Verification Workflow + +The main workflow composes reusable workflows: + +```mermaid +flowchart TD + trigger[Push, pull request, or manual dispatch] + verify[verify] + lint[Workflow and script lint] + cli[CLI checks] + msrv[MSRV check] + build[build] + test[test] + security[security] + + trigger --> verify + verify --> lint + verify --> cli + verify --> msrv + verify --> build + build --> test + trigger --> security +``` + +Rust Linux jobs run in the disposable `rust:1.89-bookworm` container. The +coverage job uses the same container and enforces at least 80% line coverage. +Native workspace tests run on `ubuntu-latest`, `macos-latest`, and +`windows-latest`. + +Workflow and shell validation runs Actionlint and ShellCheck. The repository’s +local verification equivalent is: + +```bash +make verify +``` + +The concise Make targets are `build`, `check`, `coverage`, `fmt`, `lint`, +`security`, `test`, `verify`, and `workflows`. + +### Pre-Commit Workflow + +`lefthook.yml` invokes `agentskill-scripts/pre-commit.sh` with staged paths. +The script maps paths to affected crates: + +```mermaid +flowchart TD + staged[Staged paths] + root{Cargo.toml or Cargo.lock?} + core{agentskill-core path?} + analyzers{agentskill-analyzers path?} + generation{agentskill-generation path?} + all[Run all crate checks] + coreChecks[Run core checks] + analyzerChecks[Run analyzer checks] + generationChecks[Run generation checks] + skip[Skip Rust checks] + + staged --> root + root -->|yes| all + root -->|no| core + core -->|yes| coreChecks + core -->|no| analyzers + analyzers -->|yes| analyzerChecks + analyzers -->|no| generation + generation -->|yes| generationChecks + generation -->|no| skip +``` + +For selected crates it runs formatting, clippy, and tests. If the installed +`rustc` is missing or older than Rust 1.89, it re-executes itself inside a +disposable Rust container with the repository mounted and the current user ID +preserved. This gives local hooks a reproducible fallback without modifying +the host toolchain. + +### Release Workflow + +Release is tag-driven and supports stable and release-candidate tags: + +```text +X.Y.Z stable release +X.Y.Z-rc.N prerelease candidate +``` + +The reusable release stages are: + +```mermaid +flowchart TD + prepare[prepare
Validate tag
Derive version and prerelease flag
Prepare release notes] + verify[verify
Check exact release ref] + test[test
Container coverage and native tests] + package[package
Build six platform archives] + checksum[checksum
Require six archives and create SHA256SUMS] + publish[publish
Attach notes, archives, and checksums] + + prepare --> verify --> test --> package --> checksum --> publish +``` + +The package matrix covers: + +| Platform | Target | +| --- | --- | +| Ubuntu x86 | `x86_64-unknown-linux-gnu` | +| Ubuntu ARM | `aarch64-unknown-linux-gnu` | +| macOS Intel | `x86_64-apple-darwin` | +| macOS ARM | `aarch64-apple-darwin` | +| Windows x86 | `x86_64-pc-windows-msvc` | +| Windows ARM | `aarch64-pc-windows-msvc` | + +Each archive contains both `agentskill` and `agsk` plus `LICENSE`. Packaging +smoke-tests both binaries, validates archive contents, and publishes checksums +before the release action is allowed to run. + +Stable release notes are extracted from the matching `CHANGELOG.md` heading. +RC releases receive generated notes that point operators to the final stable +release notes. The release helper rejects tags that do not match either +supported form. + +## Extension Guide + +### Adding A Language + +1. Add one `LanguageSpec` entry to the core registry. +2. Add representative files under `agentskill-skill/examples/`. +3. Extend analyzer-specific parsing only where the language needs it. +4. Add contract coverage for scan, measure, config, graph, symbols, and tests + behavior that applies to the language. +5. Update the supported-language documentation and release matrix test. + +The registry should remain the source of truth; do not duplicate extension or +test-path lists inside individual analyzers. + +### Adding An Analyzer + +1. Add a module under `agentskill-analyzers/src/`. +2. Expose it from the analyzer crate. +3. Add its name to the analyzer registry and `run_one` dispatch. +4. Define stable success and error JSON shapes. +5. Add analyzer contract tests and include its evidence in generation only when + the evidence is available. +6. Document the command in `agentskill-docs/cli.md` and `README.md`. + +### Changing Generated Markdown + +Generated headings, section order, metadata, and merge behavior are public +contracts. Update the generation tests, document the behavior, and verify all +three layouts. Keep evidence extraction in analyzers and rendering decisions +in generation; do not parse source files from markdown rendering code. + +### Changing Release Behavior + +Keep tag parsing in `agentskill-scripts/release-notes.sh`, archive validation in +`agentskill-scripts/verify-release-archive.sh`, and orchestration in reusable +workflow files. Any change to platform targets must update the package matrix, +the checksum archive count, smoke tests, and release documentation together. + +## Design Principles + +The architecture favors a small number of explicit patterns: + +- **Layering:** dependencies point toward stable shared primitives. +- **Registry and dispatch:** fixed public commands and languages have one + discoverable registration path. +- **Pipeline composition:** scan evidence flows into analyzers, then into + generation and serialization. +- **Strategy by data:** profiles and layouts change presentation while sharing + the same facts. +- **Boundary adapters:** CLI, pre-commit, release scripts, and GitHub Actions + adapt external process conventions to stable Rust library contracts. +- **Deterministic processing:** sorted inputs and bounded outputs make results + reproducible and reviewable. +- **Explicit failure handling:** expected repository failures become structured + results; invalid operator input remains a clear process error. + +The code does not introduce traits, factories, or object hierarchies merely to +match a design-pattern catalog. A direct function and a `match` are preferred +when they make the control flow easier to inspect and preserve the same +contract. diff --git a/agentskill-docs/cli.md b/agentskill-docs/cli.md new file mode 100644 index 0000000..87aca4f --- /dev/null +++ b/agentskill-docs/cli.md @@ -0,0 +1,24 @@ +# CLI Reference + +Both `agentskill` and `agsk` expose the same command surface: + +```text +analyze ... +scan +measure +config +git +graph +symbols +tests +generate +update +``` + +Analyzer commands accept `--lang` where applicable, `--pretty`, and `--out +FILE`. `analyze` accepts multiple repositories and repeatable `--reference` +flags. `generate` accepts `--reference`, `--interactive`, `--profile`, and +`--layout`. `update` accepts `--section`, `--exclude-section`, `--force`, and +`--profile`; only the `single` layout is supported for updates. + +Use `agentskill --help` and `agentskill --help` for the exact syntax. diff --git a/agentskill-generation/Cargo.toml b/agentskill-generation/Cargo.toml new file mode 100644 index 0000000..5a6fdff --- /dev/null +++ b/agentskill-generation/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "agentskill-generation" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +description = "AGENTS.md generation and update workflows for agentskill" + +[dependencies] +agentskill-analyzers.workspace = true +agentskill-core.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/agentskill-generation/src/lib.rs b/agentskill-generation/src/lib.rs new file mode 100644 index 0000000..a9febbc --- /dev/null +++ b/agentskill-generation/src/lib.rs @@ -0,0 +1,938 @@ +//! Deterministic AGENTS.md rendering and update workflows. + +use std::collections::BTreeMap; +use std::fs; +use std::io::{self, BufRead, Write}; +use std::path::{Path, PathBuf}; + +use agentskill_analyzers::run_all; +use agentskill_core::document::{Document, Section, merge, normalize_section_name, serialize}; +use agentskill_core::error::{Result, validate_repo}; +use agentskill_core::fs::read_text; +use serde_json::Value; + +pub const TITLE: &str = "# AGENTS.md\n\n"; +pub const SECTION_ORDER: &[&str] = &[ + "overview", + "repository structure", + "service map", + "cross-service boundaries", + "commands and workflows", + "code formatting", + "naming conventions", + "type annotations", + "imports", + "error handling", + "comments and docstrings", + "testing", + "git", + "dependencies and tooling", + "red lines", +]; + +const SECTION_HEADINGS: &[(&str, &str)] = &[ + ("overview", "1. Overview"), + ("repository structure", "2. Repository Structure"), + ("service map", "3. Service Map"), + ("cross-service boundaries", "4. Cross-Service Boundaries"), + ("commands and workflows", "5. Commands and Workflows"), + ("code formatting", "6. Code Formatting"), + ("naming conventions", "7. Naming Conventions"), + ("type annotations", "8. Type Annotations"), + ("imports", "9. Imports"), + ("error handling", "10. Error Handling"), + ("comments and docstrings", "11. Comments and Docstrings"), + ("testing", "12. Testing"), + ("git", "13. Git"), + ("dependencies and tooling", "14. Dependencies and Tooling"), + ("red lines", "15. Red Lines"), +]; + +const SECTION_NUMBERS: &[(&str, usize)] = &[ + ("overview", 1), + ("repository structure", 2), + ("service map", 3), + ("cross-service boundaries", 4), + ("commands and workflows", 5), + ("code formatting", 6), + ("naming conventions", 7), + ("type annotations", 8), + ("imports", 9), + ("error handling", 10), + ("comments and docstrings", 11), + ("testing", 12), + ("git", 13), + ("dependencies and tooling", 14), + ("red lines", 15), +]; + +pub fn validate_profile(profile: &str) -> Result<&str> { + match profile.trim().to_ascii_lowercase().as_str() { + "concise" => Ok("concise"), + "comprehensive" => Ok("comprehensive"), + _ => Err(agentskill_core::AgentskillError::InvalidArgument(format!( + "unsupported output profile: {profile:?} (allowed: concise, comprehensive)" + ))), + } +} + +pub fn validate_layout(layout: &str) -> Result<&str> { + match layout.trim().to_ascii_lowercase().as_str() { + "single" => Ok("single"), + "split" => Ok("split"), + "multifile" => Ok("multifile"), + _ => Err(agentskill_core::AgentskillError::InvalidArgument(format!( + "unsupported output layout: {layout:?} (allowed: single, split, multifile)" + ))), + } +} + +pub fn render( + repo: &Path, + profile: &str, + references: &[String], + interactive: bool, +) -> Result { + render_with_answers(repo, profile, references, interactive, &BTreeMap::new()) +} + +pub fn render_with_answers( + repo: &Path, + profile: &str, + references: &[String], + interactive: bool, + answers: &BTreeMap, +) -> Result { + let profile = validate_profile(profile)?; + + let reference_documents = agentskill_core::reference::load_reference_documents(references)?; + let analysis = run_all(repo.to_string_lossy().as_ref(), None); + + let mut sections = render_sections(repo, &analysis, profile)?; + apply_interactive_answers(&mut sections, answers); + + if interactive && answers.is_empty() { + sections.push(section( + "Interactive Notes", + "Review repository-specific conventions before committing this document.\n", + )); + } + + let mut document = Document { + preamble: TITLE.into(), + sections, + }; + + if !reference_documents.is_empty() { + document + .preamble + .push_str(&reference_metadata(&reference_documents)); + document.preamble.push_str("\n\n"); + } + + if !references.is_empty() { + document.preamble.push_str("> References: "); + document.preamble.push_str(&references.join(", ")); + document.preamble.push_str("\n\n"); + } + + Ok(serialize(&document)) +} + +pub fn collect_interactive_answers( + repo: &str, + references: &[String], +) -> Result> { + let root = validate_repo(repo)?; + + let documents = agentskill_core::reference::load_reference_documents(references)?; + let analysis = run_all(root.to_string_lossy().as_ref(), None); + + let reference_text = documents + .iter() + .map(|document| document.content.as_str()) + .collect::>() + .join("\n"); + + let mut gaps = Vec::new(); + let has_test_command = analysis["tests"] + .as_object() + .into_iter() + .flat_map(|items| items.values()) + .any(|item| { + item["run_command"] + .as_str() + .is_some_and(|value| !value.is_empty()) + }); + + if !has_test_command { + gaps.push(( + "test_command", + "I couldn't determine the canonical test command. Enter it, or press Enter to skip: ", + extract_reference(&reference_text, "Run command"), + )); + } + + if analysis["git"]["error"].is_string() { + gaps.push(( + "commit_prefixes", + "Git history is unavailable. Enter preferred commit prefixes, or press Enter to skip: ", + extract_reference(&reference_text, "Commit prefixes observed"), + )); + gaps.push(( + "merge_strategy", + "Git history is unavailable. Enter the preferred merge strategy, or press Enter to skip: ", + extract_reference(&reference_text, "Merge strategy"), + )); + } + + let stdin = io::stdin(); + + let mut input = stdin.lock(); + let mut answers = BTreeMap::new(); + + for (key, prompt, inferred) in gaps { + if let Some(value) = inferred { + answers.insert(key.to_string(), value); + continue; + } + eprint!("{prompt}"); + io::stderr().flush()?; + + let mut answer = String::new(); + input.read_line(&mut answer)?; + + let answer = answer.trim(); + if !answer.is_empty() { + answers.insert(key.to_string(), answer.to_string()); + } + } + + Ok(answers) +} + +fn reference_metadata(documents: &[agentskill_core::reference::ReferenceDocument]) -> String { + let references = documents + .iter() + .map(|document| { + let mut value = serde_json::Map::new(); + value.insert("kind".into(), Value::String(document.source.kind.clone())); + value.insert("value".into(), Value::String(document.source.value.clone())); + value.insert( + "source_path".into(), + Value::String(document.source_path.clone()), + ); + + if let Some(sha) = &document.commit_sha { + value.insert("commit_sha".into(), Value::String(sha.clone())); + } + Value::Object(value) + }) + .collect::>(); + + let metadata = serde_json::json!({ + "agentskill_version": env!("CARGO_PKG_VERSION"), + "references": references, + }); + + format!( + "", + serde_json::to_string_pretty(&metadata).unwrap_or_default() + ) +} + +fn extract_reference(text: &str, label: &str) -> Option { + let needle = format!("{label}:"); + text.lines().find_map(|line| { + let value = line.split_once(&needle)?.1.trim(); + + let value = value.strip_prefix('`')?.split('`').next()?; + (!value.is_empty()).then(|| value.to_string()) + }) +} + +fn apply_interactive_answers(sections: &mut [Section], answers: &BTreeMap) { + let mut notes = BTreeMap::<&str, Vec>::new(); + + if let Some(command) = answers.get("test_command") { + notes + .entry("Testing") + .or_default() + .push(format!("Use `{command}` as the canonical test command.")); + notes + .entry("Commands and Workflows") + .or_default() + .push(format!("Use `{command}` as the canonical test command.")); + } + + if let Some(prefixes) = answers.get("commit_prefixes") { + notes + .entry("Git") + .or_default() + .push(format!("Preferred commit prefixes: `{prefixes}`.")); + } + + if let Some(strategy) = answers.get("merge_strategy") { + notes + .entry("Git") + .or_default() + .push(format!("Preferred merge strategy: `{strategy}`.")); + } + + for section in sections { + let key = normalize_section_name(§ion.heading); + + if let Some(entries) = notes + .iter() + .find(|(heading, _)| normalize_section_name(heading) == key) + .map(|(_, entries)| entries) + { + let prefix = format!( + "Interactive Answers:\n{}\n\n", + entries + .iter() + .map(|entry| format!("- {entry}")) + .collect::>() + .join("\n") + ); + section.body = format!("{prefix}{}", section.body); + } + } +} + +pub fn generate( + repo: &str, + out: Option<&str>, + references: &[String], + interactive: bool, + profile: &str, + layout: &str, +) -> Result<()> { + generate_with_answers( + repo, + out, + references, + interactive, + profile, + layout, + &BTreeMap::new(), + ) +} + +pub fn generate_with_answers( + repo: &str, + out: Option<&str>, + references: &[String], + interactive: bool, + profile: &str, + layout: &str, + answers: &BTreeMap, +) -> Result<()> { + let root = validate_repo(repo)?; + + let profile = validate_profile(profile)?; + let layout = validate_layout(layout)?; + + let markdown = render_with_answers(&root, profile, references, interactive, answers)?; + match layout { + "single" => write_or_print(out.map(PathBuf::from), markdown)?, + "split" => { + let primary = out + .map(PathBuf::from) + .unwrap_or_else(|| root.join("AGENTS.md")); + + let companion = companion_path(&primary); + let linked = format!( + "{}\nSee [the comprehensive reference](./{}).\n", + markdown.trim_end(), + companion.file_name().unwrap_or_default().to_string_lossy() + ); + write_file(&primary, linked)?; + + let comprehensive = + render_with_answers(&root, "comprehensive", references, interactive, answers)?; + + let comprehensive = comprehensive + .strip_prefix(TITLE) + .map_or(comprehensive.clone(), |body| { + format!("# AGENTS Reference\n\n{body}") + }); + write_file(&companion, comprehensive)? + } + "multifile" => { + let primary = out + .map(PathBuf::from) + .unwrap_or_else(|| root.join("AGENTS.md")); + let reference_documents = + agentskill_core::reference::load_reference_documents(references)?; + let analysis = run_all(root.to_string_lossy().as_ref(), None); + + let dir = primary.parent().unwrap_or(&root).join(".agentskill"); + fs::create_dir_all(&dir)?; + + let mut sections = render_sections(&root, &analysis, profile)?; + apply_interactive_answers(&mut sections, answers); + + let mut index = String::from("# AGENTS.md\n\n"); + for section in §ions { + let key = normalize_section_name(§ion.heading); + + let number = SECTION_NUMBERS + .iter() + .find(|(name, _)| *name == key) + .map_or(0, |(_, number)| *number); + + if number == 0 { + continue; + } + + let filename_heading = section + .heading + .split_once('.') + .map_or(section.heading.as_str(), |(_, value)| value.trim()); + + let filename = format!( + "{:02}_{}.md", + number, + filename_heading.replace(' ', "_").to_ascii_uppercase() + ); + index.push_str(&format!( + "- [{}](.agentskill/{filename})\n", + section.heading + )); + write_file( + &dir.join(&filename), + format!("# {}\n\n{}", section.heading, section.body), + )?; + } + + if !reference_documents.is_empty() { + let metadata = reference_metadata(&reference_documents); + index = format!( + "# AGENTS.md\n\n{metadata}\n\n{}", + index.trim_start_matches("# AGENTS.md\n\n") + ); + } + write_file(&primary, index)?; + } + _ => unreachable!(), + } + + Ok(()) +} + +pub fn update( + repo: &str, + out: Option<&str>, + only: &[String], + exclude: &[String], + force: bool, + profile: &str, + layout: &str, +) -> Result<()> { + let layout = validate_layout(layout)?; + + if layout != "single" { + return Err(agentskill_core::AgentskillError::InvalidArgument(format!( + "update with layout '{layout}' is not implemented yet" + ))); + } + + let root = validate_repo(repo)?; + + let profile = validate_profile(profile)?; + let feedback = load_feedback(&root)?; + + let mut effective_exclude = exclude.to_vec(); + if !force { + effective_exclude.extend(feedback.preserve_sections.iter().cloned()); + } + + let mut generated_sections = render_sections( + &root, + &run_all(root.to_string_lossy().as_ref(), None), + profile, + )?; + apply_feedback(&mut generated_sections, &feedback); + validate_requested_sections(only, &effective_exclude, &generated_sections)?; + + let generated = Document { + preamble: TITLE.into(), + sections: generated_sections, + }; + + let existing_path = root.join("AGENTS.md"); + let existing = if existing_path.exists() { + read_text(&existing_path) + } else { + String::new() + }; + + let result = merge(&existing, &generated, only, &effective_exclude, force); + write_file(&out.map(PathBuf::from).unwrap_or(existing_path), result) +} + +#[derive(Default)] +struct Feedback { + sections: BTreeMap, + preserve_sections: Vec, +} + +#[derive(Default)] +struct FeedbackSection { + prepend_notes: Vec, + pinned_facts: Vec, +} + +fn load_feedback(root: &Path) -> Result { + let path = root.join(".agentskill-feedback.json"); + + if !path.exists() { + return Ok(Feedback::default()); + } + + let value: Value = serde_json::from_str(&read_text(&path)).map_err(|error| { + agentskill_core::AgentskillError::InvalidArgument(format!("invalid feedback JSON: {error}")) + })?; + + let object = value.as_object().ok_or_else(|| { + agentskill_core::AgentskillError::InvalidArgument("feedback must be an object".into()) + })?; + + let mut feedback = Feedback::default(); + if let Some(preserve) = object.get("preserve_sections") { + feedback.preserve_sections = string_list(preserve, "feedback.preserve_sections")? + .into_iter() + .map(|name| normalize_section_name(&name)) + .collect(); + feedback.preserve_sections.sort(); + feedback.preserve_sections.dedup(); + } + + if let Some(sections) = object.get("sections") { + let sections = sections.as_object().ok_or_else(|| { + agentskill_core::AgentskillError::InvalidArgument( + "feedback.sections must be an object".into(), + ) + })?; + + for (name, value) in sections { + let value = value.as_object().ok_or_else(|| { + agentskill_core::AgentskillError::InvalidArgument(format!( + "feedback.sections.{name} must be an object" + )) + })?; + + let key = normalize_section_name(name); + if feedback.sections.contains_key(&key) { + return Err(agentskill_core::AgentskillError::InvalidArgument(format!( + "duplicate feedback section after normalization: {name}" + ))); + } + feedback.sections.insert( + key, + FeedbackSection { + prepend_notes: value + .get("prepend_notes") + .map(|value| { + string_list(value, &format!("feedback.sections.{name}.prepend_notes")) + }) + .transpose()? + .unwrap_or_default(), + pinned_facts: value + .get("pinned_facts") + .map(|value| { + string_list(value, &format!("feedback.sections.{name}.pinned_facts")) + }) + .transpose()? + .unwrap_or_default(), + }, + ); + } + } + + Ok(feedback) +} + +fn string_list(value: &Value, label: &str) -> Result> { + value + .as_array() + .ok_or_else(|| { + agentskill_core::AgentskillError::InvalidArgument(format!( + "{label} must be a list of strings" + )) + })? + .iter() + .map(|value| { + value.as_str().map(str::to_string).ok_or_else(|| { + agentskill_core::AgentskillError::InvalidArgument(format!( + "{label} must be a list of strings" + )) + }) + }) + .collect() +} + +fn apply_feedback(sections: &mut [Section], feedback: &Feedback) { + for section in sections { + let key = normalize_section_name(§ion.heading); + + let Some(feedback_section) = feedback.sections.get(&key) else { + continue; + }; + + let mut notes = Vec::new(); + if !feedback_section.prepend_notes.is_empty() { + notes.push(format!( + "Maintainer Notes From `.agentskill-feedback.json`:\n{}", + feedback_section + .prepend_notes + .iter() + .map(|note| format!("- {note}")) + .collect::>() + .join("\n") + )); + } + + if !feedback_section.pinned_facts.is_empty() { + notes.push(format!( + "Pinned Facts From `.agentskill-feedback.json`:\n{}", + feedback_section + .pinned_facts + .iter() + .map(|fact| format!("- {fact}")) + .collect::>() + .join("\n") + )); + } + + if !notes.is_empty() { + section.body = format!("{}\n\n{}", notes.join("\n\n"), section.body); + } + } +} + +fn validate_requested_sections( + only: &[String], + exclude: &[String], + generated: &[Section], +) -> Result<()> { + let supported = generated + .iter() + .map(|section| normalize_section_name(§ion.heading)) + .collect::>(); + + for name in only.iter().chain(exclude) { + let key = normalize_section_name(name); + + if !supported.contains(&key) { + return Err(agentskill_core::AgentskillError::InvalidArgument(format!( + "unsupported or unavailable section: {name}" + ))); + } + } + + Ok(()) +} + +fn render_sections(repo: &Path, analysis: &Value, profile: &str) -> Result> { + let summary = &analysis["scan"]["summary"]; + + let languages = summary["by_language"] + .as_object() + .map(|items| items.keys().cloned().collect::>()) + .unwrap_or_default(); + + let language_summary = if languages.is_empty() { + "none".to_string() + } else { + languages.join(", ") + }; + + let mut sections = vec![ + section( + "1. Overview", + format!( + "agentskill analyzes repositories and synthesizes precise `AGENTS.md` guidance.\n\n- Repository: `{}`\n- Languages detected: {}\n", + repo.display(), + language_summary + ), + ), + section( + "2. Repository Structure", + format!( + "The repository contains {} analyzed source files.\n\n```text\n{}\n```\n\nRead order is derived from file size and entrypoint priority.\n", + summary["total_files"].as_u64().unwrap_or(0), + top_level_layout(summary, analysis) + ), + ), + section("5. Commands and Workflows", commands_body(analysis)), + section("6. Code Formatting", formatting_body(analysis)), + section("7. Naming Conventions", naming_body(analysis)), + section( + "8. Type Annotations", + "Use the type system and annotation style established by the repository's source and configuration.\n", + ), + section( + "9. Imports", + "Keep imports grouped and ordered consistently with the existing source files.\n", + ), + section( + "10. Error Handling", + "Handle expected failures at the command boundary and preserve machine-readable error output.\n", + ), + section( + "11. Comments and Docstrings", + "Write comments and documentation only where they clarify behavior that is not obvious from the code.\n", + ), + section("12. Testing", testing_body(analysis, languages.len())), + section("13. Git", git_body(analysis)), + section( + "14. Dependencies and Tooling", + "Use the repository's declared dependency manager and lockfile. Keep tooling configuration version-controlled.\n", + ), + section( + "15. Red Lines", + "Do not change public contracts, generated-document semantics, or repository-specific conventions without updating their tests and documentation.\n", + ), + ]; + + let boundaries = analysis["graph"]["monorepo_boundaries"].clone(); + if boundaries["detected"].as_bool().unwrap_or(false) { + let services = boundaries["services"] + .as_array() + .cloned() + .unwrap_or_default(); + + let service_lines = services + .iter() + .filter_map(Value::as_str) + .map(|service| format!("- `{service}`: service root at `{service}`")) + .collect::>(); + sections.insert( + 2, + section("3. Service Map", format!("{}\n", service_lines.join("\n"))), + ); + + let imports = boundaries["cross_service_imports"] + .as_array() + .is_some_and(|items| !items.is_empty()); + sections.insert( + 3, + section( + "4. Cross-Service Boundaries", + if imports { + "- Cross-service imports were detected; review shared contracts before changing service boundaries.\n" + } else { + "- No cross-service imports were detected; preserve service boundaries unless a shared contract layer is introduced.\n" + }, + ), + ); + } + + for item in &mut sections { + if let Some((_, heading)) = SECTION_HEADINGS + .iter() + .find(|(key, _)| normalize_section_name(&item.heading) == *key) + { + item.heading = (*heading).into(); + } + } + + if profile == "comprehensive" { + for section in &mut sections { + section.body.push_str( + "\nVerify this rule against representative source files before making a change.\n", + ); + } + } + + Ok(sections) +} + +fn top_level_layout(summary: &Value, analysis: &Value) -> String { + let mut groups = BTreeMap::::new(); + + if let Some(tree) = analysis["scan"]["tree"].as_array() { + for entry in tree { + if let Some(path) = entry["path"].as_str() { + let root = path.split('/').next().unwrap_or(path); + *groups.entry(root.to_string()).or_default() += 1; + } + } + } + + if groups.is_empty() { + return format!("# {} files", summary["total_files"].as_u64().unwrap_or(0)); + } + groups + .into_iter() + .map(|(name, count)| format!("{name} # {count} files")) + .collect::>() + .join("\n") +} + +fn commands_body(analysis: &Value) -> String { + let mut commands = Vec::new(); + + if let Some(items) = analysis["tests"].as_object() { + for value in items.values() { + if let Some(command) = value["run_command"].as_str() + && !command.is_empty() + && !commands.iter().any(|item| item == command) + { + commands.push(command.to_string()); + } + } + } + + if commands.is_empty() { + commands.push("No canonical test command was detected.".into()); + } + + format!( + "```bash\n{}\n```\n\n- Keep local verification aligned with the repository's configured test and tooling commands.\n", + commands.join("\n") + ) +} + +fn formatting_body(analysis: &Value) -> String { + let mut body = String::new(); + + if let Some(languages) = analysis["config"].as_object() { + for (language, config) in languages { + if language == "editorconfig" { + continue; + } + + let tools = ["formatter", "linter", "type_checker"] + .iter() + .filter_map(|kind| config[*kind]["name"].as_str().map(|name| (*kind, name))) + .collect::>(); + + if tools.is_empty() { + continue; + } + body.push_str(&format!("### {}\n\n", title_case(language))); + + for (kind, name) in tools { + body.push_str(&format!("- Use `{name}` as the configured {kind}.\n")); + } + body.push('\n'); + } + } + + if body.is_empty() { + body.push_str("No formatter or linter configuration was detected.\n"); + } + body +} + +fn naming_body(analysis: &Value) -> String { + let mut lines = vec![ + "Match the dominant identifier and file naming patterns already present in each language." + .into(), + ]; + + if let Some(languages) = analysis["symbols"].as_object() { + for (language, symbols) in languages { + let mut patterns = Vec::new(); + + for kind in ["functions", "classes", "constants"] { + if let Some(items) = symbols[kind]["patterns"].as_object() { + patterns.extend(items.keys().cloned()); + } + } + patterns.sort(); + patterns.dedup(); + + if !patterns.is_empty() { + lines.push(format!( + "- `{}` uses observed patterns: `{}`.", + title_case(language), + patterns.join("`, `") + )); + } + } + } + + format!("{}\n", lines.join("\n")) +} + +fn testing_body(analysis: &Value, language_count: usize) -> String { + let mut lines = vec![format!( + "Run the detected test commands and preserve coverage for the {language_count} detected language families." + )]; + + if let Some(items) = analysis["tests"].as_object() { + for (language, value) in items { + let framework = value["framework"].as_str().unwrap_or("unknown"); + + let command = value["run_command"].as_str().unwrap_or("unknown"); + lines.push(format!( + "- `{}`: `{framework}` via `{command}`; {} source files and {} test files.", + title_case(language), + value["source_files"].as_u64().unwrap_or(0), + value["test_files"].as_u64().unwrap_or(0) + )); + } + } + + format!("{}\n", lines.join("\n")) +} + +fn git_body(analysis: &Value) -> String { + let git = &analysis["git"]; + + if let Some(prefixes) = git["prefixes"].as_object() { + let names = prefixes.keys().cloned().collect::>().join(", "); + + return format!( + "Observed commit prefixes include `{names}`. Preserve the repository's branch and merge conventions.\n" + ); + } + "Git history was unavailable; confirm commit, branch, and merge conventions before contributing.\n".into() +} + +fn title_case(value: &str) -> String { + value + .split(['-', '_']) + .map(|part| { + let mut chars = part.chars(); + chars.next().map_or_else(String::new, |first| { + first.to_uppercase().collect::() + chars.as_str() + }) + }) + .collect::>() + .join(" ") +} + +fn section(heading: &str, body: impl Into) -> Section { + Section { + level: 2, + heading: heading.into(), + body: body.into(), + } +} + +fn companion_path(primary: &Path) -> PathBuf { + primary.with_file_name("AGENTS.reference.md") +} +fn write_or_print(path: Option, text: String) -> Result<()> { + match path { + Some(path) => write_file(&path, text), + None => { + print!("{text}"); + + Ok(()) + } + } +} +fn write_file(path: &Path, text: String) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, text)?; + + Ok(()) +} diff --git a/agentskill-generation/tests/workflows.rs b/agentskill-generation/tests/workflows.rs new file mode 100644 index 0000000..9bd5439 --- /dev/null +++ b/agentskill-generation/tests/workflows.rs @@ -0,0 +1,270 @@ +use std::collections::BTreeMap; +use std::fs; + +use agentskill_generation::{generate, render, render_with_answers, update}; +use tempfile::tempdir; + +#[test] +fn renders_deterministic_sections() { + let example = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../agentskill-skill/examples/rust"); + + let markdown = render(&example, "concise", &[], false).unwrap(); + assert!(markdown.starts_with("# AGENTS.md")); + + assert!(markdown.contains("## 1. Overview")); + assert!(markdown.contains("## 12. Testing")); + + let comprehensive = render(&example, "comprehensive", &[], true).unwrap(); + + assert!(comprehensive.contains("Interactive Notes")); + assert!(comprehensive.contains("Verify this rule")); +} + +#[test] +fn embeds_reference_metadata_in_generated_documents() { + let directory = tempdir().unwrap(); + + let reference = tempdir().unwrap(); + fs::write(directory.path().join("main.rs"), "fn main() {}\n").unwrap(); + fs::write( + reference.path().join("AGENTS.md"), + "# Reference\n\nUse cargo test.\n", + ) + .unwrap(); + + let markdown = render( + directory.path(), + "concise", + &[reference.path().to_string_lossy().into_owned()], + false, + ) + .unwrap(); + + assert!(markdown.starts_with("# AGENTS.md\n\n").map(|(metadata, _)| metadata)) + .and_then(|metadata| serde_json::from_str::(metadata).ok()) + .expect("multifile metadata should be valid JSON"); + + assert_eq!( + metadata["references"][0]["value"], + reference.path().to_string_lossy().to_string() + ); +} + +#[test] +fn update_preserves_and_filters_manual_sections() { + let directory = tempdir().unwrap(); + fs::write(directory.path().join("main.rs"), "fn main() {}\n").unwrap(); + fs::write( + directory.path().join("AGENTS.md"), + "# AGENTS.md\n\n## Testing\n\nmanual testing\n\n## Custom\n\nkeep this\n", + ) + .unwrap(); + + let repo = directory.path().to_string_lossy().to_string(); + + update( + &repo, + None, + &[], + &["testing".into()], + false, + "concise", + "single", + ) + .unwrap(); + + let merged = fs::read_to_string(directory.path().join("AGENTS.md")).unwrap(); + assert!(merged.contains("manual testing")); + + assert!(merged.contains("keep this")); + + update(&repo, None, &[], &[], true, "concise", "single").unwrap(); + + let forced = fs::read_to_string(directory.path().join("AGENTS.md")).unwrap(); + assert!(!forced.contains("keep this")); +} + +#[test] +fn applies_interactive_answers_and_feedback_sidecar() { + let directory = tempdir().unwrap(); + fs::write(directory.path().join("main.rs"), "fn main() {}\n").unwrap(); + fs::write( + directory.path().join("AGENTS.md"), + "# AGENTS.md\n\n## Git\n\nTeam merge policy.\n", + ) + .unwrap(); + fs::write( + directory.path().join(".agentskill-feedback.json"), + r#"{ + "sections": { + "Testing": { + "prepend_notes": ["Keep integration tests fast."], + "pinned_facts": ["The test command is cargo test."] + } + }, + "preserve_sections": ["Git", "git"] + }"#, + ) + .unwrap(); + + let repo = directory.path().to_string_lossy().to_string(); + let mut answers = BTreeMap::new(); + answers.insert("test_command".into(), "cargo test --all".into()); + + let markdown = render_with_answers(directory.path(), "concise", &[], true, &answers).unwrap(); + assert!(markdown.contains("Use `cargo test --all` as the canonical test command.")); + + update(&repo, None, &[], &[], false, "concise", "single").unwrap(); + + let updated = fs::read_to_string(directory.path().join("AGENTS.md")).unwrap(); + assert!(updated.contains("Keep integration tests fast.")); + + assert!(updated.contains("## Git")); + + fs::write(directory.path().join(".agentskill-feedback.json"), "[]").unwrap(); + + assert!(update(&repo, None, &[], &[], false, "concise", "single").is_err()); + assert!( + update( + &repo, + None, + &["unknown".into()], + &[], + false, + "concise", + "single", + ) + .is_err() + ); +} + +#[test] +fn renders_detected_monorepo_services() { + let directory = tempdir().unwrap(); + fs::create_dir_all(directory.path().join("services/api")).unwrap(); + fs::create_dir_all(directory.path().join("services/web")).unwrap(); + fs::write( + directory.path().join("services/api/main.rs"), + "fn main() {}\n", + ) + .unwrap(); + fs::write( + directory.path().join("services/web/main.rs"), + "fn main() {}\n", + ) + .unwrap(); + + let markdown = render(directory.path(), "concise", &[], false).unwrap(); + + assert!(markdown.contains("## 3. Service Map")); + assert!(markdown.contains("- `api`: service root at `api`")); + assert!(markdown.contains("- `web`: service root at `web`")); +} diff --git a/agentskill-scripts/README.md b/agentskill-scripts/README.md new file mode 100644 index 0000000..ff818dd --- /dev/null +++ b/agentskill-scripts/README.md @@ -0,0 +1,19 @@ +# Agentskill Release Scripts + +These scripts are used by the reusable GitHub Actions release workflows. + +- `release-notes.sh` validates numeric release tags and extracts final notes + from `CHANGELOG.md`. +- `verify-release-archive.sh` checks that every archive contains both CLI + binaries and the MIT license. +- `pre-commit.sh` runs filtered Rust checks for changed crates and uses a + disposable Rust container when the required local toolchain is unavailable. + +## Local Validation + +Install `actionlint` and `shellcheck`, then run: + +```bash +actionlint -color +shellcheck --shell=bash agentskill-scripts/*.sh +``` diff --git a/agentskill-scripts/pre-commit.sh b/agentskill-scripts/pre-commit.sh new file mode 100755 index 0000000..cb238d1 --- /dev/null +++ b/agentskill-scripts/pre-commit.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(git rev-parse --show-toplevel)" +cd "$root" + +required_rust="${AGENTSKILL_RUST_VERSION:-1.89}" +rust_image="${AGENTSKILL_RUST_IMAGE:-rust:${required_rust}-bookworm}" +in_container="${AGENTSKILL_PRECOMMIT_CONTAINER:-0}" +files=("$@") + +if [[ ! "$required_rust" =~ ^[0-9]+\.[0-9]+$ ]]; then + echo "AGENTSKILL_RUST_VERSION must use major.minor format: $required_rust" >&2 + exit 2 +fi + +if [ "${#files[@]}" -eq 0 ]; then + while IFS= read -r -d '' file; do + files+=("$file") + done < <(git diff --cached --name-only -z --diff-filter=ACMR) +fi + +packages="" +all_packages=0 + +add_package() { + case " $packages " in + *" $1 "*) ;; + *) packages="$packages $1" ;; + esac +} + +for file in "${files[@]}"; do + case "$file" in + Cargo.toml|Cargo.lock) + all_packages=1 + ;; + agentskill/Cargo.toml|agentskill/src/*|agentskill/tests/*) + add_package agentskill + ;; + agentskill-core/Cargo.toml|agentskill-core/src/*|agentskill-core/tests/*) + add_package agentskill-core + ;; + agentskill-analyzers/Cargo.toml|agentskill-analyzers/src/*|agentskill-analyzers/tests/*) + add_package agentskill-analyzers + ;; + agentskill-generation/Cargo.toml|agentskill-generation/src/*|agentskill-generation/tests/*) + add_package agentskill-generation + ;; + esac +done + +if [ "$all_packages" -eq 1 ]; then + packages=" agentskill agentskill-core agentskill-analyzers agentskill-generation" +fi + +if [ -z "$packages" ]; then + exit 0 +fi + +rust_is_compatible() { + command -v rustc >/dev/null 2>&1 || return 1 + + local installed major minor required_major required_minor + installed="$(rustc --version | awk '{print $2}')" + required_major="${required_rust%%.*}" + required_minor="${required_rust#*.}" + + if [[ ! "$installed" =~ ^([0-9]+)\.([0-9]+) ]]; then + return 1 + fi + + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + ((major > required_major || (major == required_major && minor >= required_minor))) +} + +if ! rust_is_compatible; then + if [ "$in_container" = "1" ]; then + echo "Rust $required_rust or newer is required" >&2 + exit 1 + fi + + if ! command -v docker >/dev/null 2>&1; then + echo "Rust $required_rust or newer is required, and Docker is unavailable" >&2 + exit 1 + fi + + exec docker run --rm \ + --env AGENTSKILL_PRECOMMIT_CONTAINER=1 \ + --env AGENTSKILL_RUST_VERSION="$required_rust" \ + --user "$(id -u):$(id -g)" \ + --volume "$root:/workspace" \ + --workdir /workspace \ + "$rust_image" \ + bash -c 'rustup component add rustfmt clippy && exec bash agentskill-scripts/pre-commit.sh "$@"' \ + -- "${files[@]}" +fi + +for package in $packages; do + cargo fmt --package "$package" -- --check +done + +for package in $packages; do + cargo clippy --package "$package" --all-targets --locked -- -D warnings +done + +for package in $packages; do + cargo test --package "$package" --locked +done diff --git a/agentskill-scripts/release-notes.sh b/agentskill-scripts/release-notes.sh new file mode 100755 index 0000000..315be43 --- /dev/null +++ b/agentskill-scripts/release-notes.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +tag="$1" +output_file="$2" + +if [[ ! "$tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "release tag must use X.Y.Z or X.Y.Z-rc.N format: $tag" >&2 + exit 1 +fi + +version="${tag%-rc.*}" +declared_version="$(tr -d '[:space:]' < VERSION)" + +if [ "$version" != "$declared_version" ]; then + echo "release tag $tag does not match VERSION $declared_version" >&2 + exit 1 +fi + +if [[ "$tag" == *-rc.* ]]; then + cat > "$output_file" < "$output_file" + +if [ ! -s "$output_file" ]; then + echo "CHANGELOG.md section for $version is empty or missing" >&2 + exit 1 +fi diff --git a/agentskill-scripts/verify-release-archive.sh b/agentskill-scripts/verify-release-archive.sh new file mode 100755 index 0000000..8e1694f --- /dev/null +++ b/agentskill-scripts/verify-release-archive.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +archive="$1" +target="$2" +workdir="$(mktemp -d)" +trap 'rm -rf "$workdir"' EXIT + +case "$archive" in + *.tar.gz) tar -xzf "$archive" -C "$workdir" ;; + *.zip) unzip -q "$archive" -d "$workdir" ;; + *) echo "unsupported archive format: $archive" >&2; exit 1 ;; +esac + +if [[ "$target" == *windows* ]]; then + binaries=(agentskill.exe agsk.exe) +else + binaries=(agentskill agsk) +fi + +for required in "${binaries[@]}" LICENSE; do + if ! find "$workdir" -type f -name "$required" | grep -q .; then + echo "archive $archive is missing $required" >&2 + exit 1 + fi +done + diff --git a/agentskill-skill/README.md b/agentskill-skill/README.md new file mode 100644 index 0000000..9af79e5 --- /dev/null +++ b/agentskill-skill/README.md @@ -0,0 +1,11 @@ +# Agentskill Skill Package + +This directory is the complete AI-assisted skill package for agentskill. + +- `SKILL.md` defines the operating workflow. +- `SYSTEM.md` defines the generated `AGENTS.md` contract. +- `references/` contains extraction and synthesis guidance. +- `examples/` contains target-language fixtures and reference outputs. + +The package is intentionally separate from the Rust runtime and can be copied +as a self-contained skill asset. diff --git a/agentskill-skill/SKILL.md b/agentskill-skill/SKILL.md new file mode 100644 index 0000000..3a703c2 --- /dev/null +++ b/agentskill-skill/SKILL.md @@ -0,0 +1,63 @@ +--- +name: agentskill +description: Let any agent produce code consistent with the existing codebase. +--- + +# Agentskill Skill + +Use the agentskill binary to gather repository evidence, then author the final +`AGENTS.md` from that evidence. The skill is AI-led: analyzer output is raw +material, not the finished document. + +## Workflow + +1. Confirm the target repository path or paths. +2. Collect broad evidence with `agentskill analyze --pretty`. +3. Inspect representative entrypoints, core modules, tests, manifests, and + configuration files directly. +4. Read `SYSTEM.md` fully before drafting the document. +5. Synthesize and validate the final `AGENTS.md` against observed conventions. + +Use individual analyzers when a focused signal is needed: + +```bash +agentskill scan --pretty +agentskill measure --pretty +agentskill config --pretty +agentskill git --pretty +agentskill graph --pretty +agentskill symbols --pretty +agentskill tests --pretty +``` + +References may be supplied to `analyze` with repeated `--reference` flags. +References are explicit inputs and must contain a readable `AGENTS.md`. + +## Static CLI Workflows + +Use these when the user explicitly wants deterministic runtime-generated +markdown rather than AI-authored synthesis: + +```bash +agentskill generate +agentskill generate --profile comprehensive +agentskill generate --layout split --out AGENTS.md +agentskill generate --layout multifile --out AGENTS.md +agentskill update +agentskill update --section testing +``` + +`update` preserves untouched manual sections by default. `--force` rebuilds +from regenerated sections. `update` supports only the `single` layout. + +## Evidence Rules + +- Extract rules from source and configuration; do not guess. +- Treat analyzer counts as evidence, not prose for the final document. +- Scope every rule to the repository, service, or target language where it + applies. +- Surface genuine uncertainty instead of silently inventing conventions. +- Keep supported target languages unchanged, including Python, while keeping + the agentskill implementation Rust-only. + +Read `SYSTEM.md` for the complete generated-document contract. diff --git a/agentskill-skill/SYSTEM.md b/agentskill-skill/SYSTEM.md new file mode 100644 index 0000000..280d2ce --- /dev/null +++ b/agentskill-skill/SYSTEM.md @@ -0,0 +1,53 @@ +# SYSTEM.md — Agentskill Generation Contract + +This document is the behavioral source of truth for generated `AGENTS.md` +files. Generated guidance must be grounded in repository evidence and must +allow an agent to produce code consistent with the analyzed repository. + +## Required Sections + +Generate these sections in this order when evidence is available: + +1. Overview +2. Repository Structure +3. Service Map +4. Cross-Service Boundaries +5. Commands and Workflows +6. Code Formatting +7. Naming Conventions +8. Type Annotations +9. Imports +10. Error Handling +11. Comments and Docstrings +12. Testing +13. Git +14. Dependencies and Tooling +15. Red Lines + +Omit sections that have no applicable evidence. Scope language-specific rules +under the relevant language or service and never apply one ecosystem's rules to +another. Preserve concrete examples when they are necessary to make a rule +operational. + +## Generation Modes + +The Rust CLI provides deterministic `generate` and `update` workflows. This +packaged skill provides AI-assisted synthesis: the model reads analyzer JSON, +source files, configuration, tests, and this specification before writing the +final document. The model must not delegate AI-authored output to the static +generator. + +`generate` creates a fresh document. `update` parses the existing document, +regenerates selected sections, preserves untouched custom content, and supports +`--force` for a clean rebuild. Profiles are `concise` and `comprehensive`; +layouts are `single`, `split`, and `multifile` for generation, while update is +single-file only. + +## Quality Requirements + +- Never invent commands, tools, file paths, or conventions. +- Prefer source-backed rules over analyzer statistics. +- Surface unresolved ambiguity or preserve existing manual text. +- Keep markdown headings, links, code fences, and trailing newline behavior + valid and deterministic. +- Update this specification whenever generation semantics change. diff --git a/examples/MONOREPO.md b/agentskill-skill/examples/MONOREPO.md similarity index 96% rename from examples/MONOREPO.md rename to agentskill-skill/examples/MONOREPO.md index 7d06824..5c3b678 100644 --- a/examples/MONOREPO.md +++ b/agentskill-skill/examples/MONOREPO.md @@ -111,7 +111,7 @@ No contract testing layer exists. Breaking changes to `helix.proto` must be back ## 5. Commands and Workflows -### gateway (Rust) +### Gateway (Rust) ```bash # Build @@ -130,7 +130,7 @@ cargo clippy -- -D warnings cargo fmt ``` -### worker (Go) +### Worker (Go) ```bash # Build @@ -149,26 +149,26 @@ golangci-lint run ./... gofmt -w . ``` -### pipeline (Python) +### Pipeline (Python) ```bash -# Install (dev mode) -pip install -e ".[dev]" +# Install development dependencies +uv sync --dev # Test -pytest +uv run pytest # Lint -ruff check pipeline/ tests/ +uv run ruff check pipeline/ tests/ # Format -ruff format pipeline/ tests/ +uv run ruff format pipeline/ tests/ # Type check -mypy pipeline/ +uv run mypy pipeline/ ``` -### Repo-wide +### Repo-Wide ```bash # Regenerate Protobuf stubs for all languages @@ -182,7 +182,7 @@ mypy pipeline/ ## 6. Code Formatting -### Rust (gateway) +### Rust (Gateway) Formatted by `rustfmt`. Config in `gateway/rustfmt.toml`. @@ -231,7 +231,7 @@ use crate::models::{Job, JobStatus}; --- -### Go (worker) +### Go (Worker) Formatted by `gofmt`. All patterns enforced by the formatter. @@ -278,7 +278,7 @@ rdb := redis.NewClient(&redis.Options{ --- -### Python (pipeline) +### Python (Pipeline) Formatted by `ruff format`. Config in `pipeline/pyproject.toml` under `[tool.ruff.format]`. @@ -319,7 +319,7 @@ from pipeline.models import RawEvent ## 7. Naming Conventions -### Rust (gateway) +### Rust (Gateway) **Functions and methods:** `snake_case`. Handlers named `handle_`: `handle_submit`, `handle_auth`, `handle_health`. @@ -340,7 +340,7 @@ static DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); --- -### Go (worker) +### Go (Worker) **Functions and methods:** `camelCase` unexported, `PascalCase` exported. Verb-first: `enqueue`, `Consume`, `registerHandler`. @@ -354,7 +354,7 @@ static DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); --- -### Python (pipeline) +### Python (Pipeline) **Functions:** `snake_case`: `ingest_batch`, `transform`, `flush_sink`. @@ -377,19 +377,19 @@ MAX_RETRIES: int = 3 ## 8. Type Annotations -### Rust (gateway) +### Rust (Gateway) - All function signatures are fully typed — the compiler enforces this. - Use `Result` for all fallible functions. Do not return `Result>` in public API functions. - Use `Option` for optional values. Never represent absence with a sentinel value. -### Go (worker) +### Go (Worker) - All exported functions have explicit parameter and return types — the compiler enforces this. - `error` is always the last return value when a function can fail. - `context.Context` is the first parameter of all functions that perform I/O. -### Python (pipeline) +### Python (Pipeline) - Annotate every function signature — both parameters and return type. - Use built-in generics: `list[str]`, `dict[str, Any]`. Never import from `typing` for these. @@ -400,17 +400,17 @@ MAX_RETRIES: int = 3 ## 9. Imports -### Rust (gateway) +### Rust (Gateway) - Three groups: `std`, external crates, `crate`. Blank lines between groups. Alphabetical within groups. - No glob imports (`use foo::*`) except in test modules where `use super::*` is acceptable. -### Go (worker) +### Go (Worker) - Two groups: stdlib, external. Blank line between groups. `goimports` manages ordering. - No dot imports. No blank import aliases except for side-effect registration. -### Python (pipeline) +### Python (Pipeline) - Three groups: stdlib, third-party, local. `isort` profile `"black"`. Never `import *`. - Local imports use the full package path: `from pipeline.models import RawEvent`. @@ -419,7 +419,7 @@ MAX_RETRIES: int = 3 ## 10. Error Handling -### Rust (gateway) +### Rust (Gateway) - All fallible functions return `Result`. `AppError` is defined in `gateway/src/errors.rs` and implements `IntoResponse` for automatic HTTP error conversion. - Propagate with `?`. Wrap external errors with context using `.map_err(|e| AppError::upstream(e))`. @@ -439,7 +439,7 @@ pub async fn handle_auth( } ``` -### Go (worker) +### Go (Worker) - All errors are returned to the caller. No silent swallowing. - Wrap errors at every layer with `fmt.Errorf("context: %w", err)`. @@ -459,7 +459,7 @@ func (c *Consumer) poll(ctx context.Context) (*Job, error) { } ``` -### Python (pipeline) +### Python (Pipeline) - All custom exceptions defined in `pipeline/errors.py`, inheriting from `PipelineError`. - Public functions raise typed exceptions — never bare `Exception` or `ValueError`. @@ -481,7 +481,7 @@ def ingest_batch(messages: list[bytes]) -> list[RawEvent]: ## 11. Comments and Docstrings -### Rust (gateway) +### Rust (Gateway) **Exported items:** `///` doc comments on all exported structs, enums, functions, and trait implementations. One sentence minimum. Full `rustdoc` format for complex items. @@ -496,7 +496,7 @@ pub async fn validate(&self, token: &str) -> Result { **Inline:** `//` with one space. Two spaces before when appended to a line of code. -### Go (worker) +### Go (Worker) **Exported symbols:** GoDoc comment starting with the symbol name. One sentence minimum. @@ -507,7 +507,7 @@ type Executor struct { **Inline:** `//` with one space. -### Python (pipeline) +### Python (Pipeline) **Modules:** One-sentence module docstring describing the module's role. @@ -521,7 +521,7 @@ type Executor struct { ## 12. Testing -### gateway (Rust) +### Gateway (Rust) Framework: `cargo test` + `tokio::test` for async. @@ -546,7 +546,7 @@ mod tests { } ``` -### worker (Go) +### Worker (Go) Framework: standard library `testing` + `testify/assert`. @@ -570,7 +570,7 @@ func TestConsumer_poll_malformedJSON(t *testing.T) { } ``` -### pipeline (Python) +### Pipeline (Python) Framework: `pytest`. Config in `pyproject.toml` under `[tool.pytest.ini_options]`. @@ -637,7 +637,7 @@ chore/upgrade-proto-toolchain ## 14. Dependencies and Tooling -### gateway (Rust) +### Gateway (Rust) - **Package manager:** Cargo. `Cargo.lock` is committed. - **Add a dependency:** `cargo add @`. @@ -645,7 +645,7 @@ chore/upgrade-proto-toolchain - **Formatter:** `rustfmt`. Config in `gateway/rustfmt.toml`. - **Minimum Rust:** 1.75 (declared in `gateway/Cargo.toml` under `[workspace.package]`). -### worker (Go) +### Worker (Go) - **Package manager:** Go modules. `go.sum` is committed. - **Add a dependency:** `go get @` then `go mod tidy`. @@ -653,16 +653,16 @@ chore/upgrade-proto-toolchain - **Formatter:** `gofmt` via `goimports`. - **Minimum Go:** 1.22 (declared in `worker/go.mod`). -### pipeline (Python) +### Pipeline (Python) -- **Package manager:** pip with `pyproject.toml`. -- **Install:** `pip install -e ".[dev]"`. +- **Package manager:** uv with `pyproject.toml`. +- **Install:** `uv sync --dev`. - **Add a dependency:** Add to `[project.dependencies]` in `pipeline/pyproject.toml`. - **Linter/formatter:** `ruff`. Config in `pipeline/pyproject.toml`. - **Type checker:** `mypy`. Config in `pipeline/pyproject.toml`. - **Minimum Python:** 3.11. -### Repo-wide +### Repo-Wide - **CI:** GitHub Actions. One workflow per service in `.github/workflows/`. - **Proto generation:** `buf`. Config in `proto/buf.yaml`. Regenerate with `./scripts/gen-proto.sh`. diff --git a/examples/MULTI_LANGUAGE.md b/agentskill-skill/examples/MULTI_LANGUAGE.md similarity index 98% rename from examples/MULTI_LANGUAGE.md rename to agentskill-skill/examples/MULTI_LANGUAGE.md index a73aa7f..4e56fab 100644 --- a/examples/MULTI_LANGUAGE.md +++ b/agentskill-skill/examples/MULTI_LANGUAGE.md @@ -62,8 +62,8 @@ lumen/ ## 5. Commands and Workflows ```bash -# Install Python package (dev mode) -pip install -e ".[dev]" +# Install Python Development Dependencies +uv sync --dev # Install TypeScript dependencies npm install @@ -566,9 +566,9 @@ chore/vitest-upgrade ### Python -- **Package manager:** pip with `pyproject.toml`. No `requirements.txt`. -- **Install:** `pip install -e ".[dev]"`. -- **Add a dependency:** Add to `[project.dependencies]` in `pyproject.toml`. Run `pip install -e ".[dev]"` to update the environment. +- **Package manager:** uv with `pyproject.toml`. No `requirements.txt`. +- **Install:** `uv sync --dev`. +- **Add a dependency:** Add to `[project.dependencies]` in `pyproject.toml`. Run `uv sync` to update the environment. - **Linter/formatter:** `ruff`. Config in `pyproject.toml` under `[tool.ruff]` and `[tool.ruff.format]`. - **Type checker:** `mypy`. Config in `pyproject.toml` under `[tool.mypy]`. - **Minimum Python:** 3.11. diff --git a/examples/README.md b/agentskill-skill/examples/README.md similarity index 88% rename from examples/README.md rename to agentskill-skill/examples/README.md index c30945c..67ec60b 100644 --- a/examples/README.md +++ b/agentskill-skill/examples/README.md @@ -38,9 +38,9 @@ Available example repos: ## Typical Commands ```bash -agentskill analyze examples/python --pretty -agentskill scan examples/typescript --pretty -agentskill generate examples/mixed +agentskill analyze agentskill-skill/examples/python --pretty +agentskill scan agentskill-skill/examples/typescript --pretty +agentskill generate agentskill-skill/examples/mixed ``` Use the installed `agentskill` CLI as the canonical interface. The example diff --git a/examples/SINGLE_LANGUAGE.md b/agentskill-skill/examples/SINGLE_LANGUAGE.md similarity index 100% rename from examples/SINGLE_LANGUAGE.md rename to agentskill-skill/examples/SINGLE_LANGUAGE.md diff --git a/examples/bash/.editorconfig b/agentskill-skill/examples/bash/.editorconfig similarity index 100% rename from examples/bash/.editorconfig rename to agentskill-skill/examples/bash/.editorconfig diff --git a/examples/bash/scripts/deploy.sh b/agentskill-skill/examples/bash/scripts/deploy.sh similarity index 100% rename from examples/bash/scripts/deploy.sh rename to agentskill-skill/examples/bash/scripts/deploy.sh diff --git a/examples/bash/scripts/lib/common.sh b/agentskill-skill/examples/bash/scripts/lib/common.sh similarity index 100% rename from examples/bash/scripts/lib/common.sh rename to agentskill-skill/examples/bash/scripts/lib/common.sh diff --git a/examples/bash/tests/deploy.bats b/agentskill-skill/examples/bash/tests/deploy.bats similarity index 100% rename from examples/bash/tests/deploy.bats rename to agentskill-skill/examples/bash/tests/deploy.bats diff --git a/examples/bash/tests/deploy_test.sh b/agentskill-skill/examples/bash/tests/deploy_test.sh similarity index 100% rename from examples/bash/tests/deploy_test.sh rename to agentskill-skill/examples/bash/tests/deploy_test.sh diff --git a/examples/c/Makefile b/agentskill-skill/examples/c/Makefile similarity index 100% rename from examples/c/Makefile rename to agentskill-skill/examples/c/Makefile diff --git a/examples/c/src/main.c b/agentskill-skill/examples/c/src/main.c similarity index 100% rename from examples/c/src/main.c rename to agentskill-skill/examples/c/src/main.c diff --git a/examples/c/src/util.c b/agentskill-skill/examples/c/src/util.c similarity index 100% rename from examples/c/src/util.c rename to agentskill-skill/examples/c/src/util.c diff --git a/examples/c/src/util.h b/agentskill-skill/examples/c/src/util.h similarity index 100% rename from examples/c/src/util.h rename to agentskill-skill/examples/c/src/util.h diff --git a/examples/c/tests/util_test.c b/agentskill-skill/examples/c/tests/util_test.c similarity index 100% rename from examples/c/tests/util_test.c rename to agentskill-skill/examples/c/tests/util_test.c diff --git a/examples/cpp/CMakeLists.txt b/agentskill-skill/examples/cpp/CMakeLists.txt similarity index 100% rename from examples/cpp/CMakeLists.txt rename to agentskill-skill/examples/cpp/CMakeLists.txt diff --git a/examples/cpp/include/example/service.hpp b/agentskill-skill/examples/cpp/include/example/service.hpp similarity index 100% rename from examples/cpp/include/example/service.hpp rename to agentskill-skill/examples/cpp/include/example/service.hpp diff --git a/examples/cpp/src/app.cpp b/agentskill-skill/examples/cpp/src/app.cpp similarity index 100% rename from examples/cpp/src/app.cpp rename to agentskill-skill/examples/cpp/src/app.cpp diff --git a/examples/cpp/tests/app_test.cpp b/agentskill-skill/examples/cpp/tests/app_test.cpp similarity index 100% rename from examples/cpp/tests/app_test.cpp rename to agentskill-skill/examples/cpp/tests/app_test.cpp diff --git a/examples/csharp/Example.csproj b/agentskill-skill/examples/csharp/Example.csproj similarity index 100% rename from examples/csharp/Example.csproj rename to agentskill-skill/examples/csharp/Example.csproj diff --git a/examples/csharp/src/App.cs b/agentskill-skill/examples/csharp/src/App.cs similarity index 100% rename from examples/csharp/src/App.cs rename to agentskill-skill/examples/csharp/src/App.cs diff --git a/examples/csharp/src/Core/UserService.cs b/agentskill-skill/examples/csharp/src/Core/UserService.cs similarity index 100% rename from examples/csharp/src/Core/UserService.cs rename to agentskill-skill/examples/csharp/src/Core/UserService.cs diff --git a/examples/csharp/tests/UserServiceTests.cs b/agentskill-skill/examples/csharp/tests/UserServiceTests.cs similarity index 100% rename from examples/csharp/tests/UserServiceTests.cs rename to agentskill-skill/examples/csharp/tests/UserServiceTests.cs diff --git a/examples/go/cmd/app/main.go b/agentskill-skill/examples/go/cmd/app/main.go similarity index 100% rename from examples/go/cmd/app/main.go rename to agentskill-skill/examples/go/cmd/app/main.go diff --git a/examples/go/go.mod b/agentskill-skill/examples/go/go.mod similarity index 100% rename from examples/go/go.mod rename to agentskill-skill/examples/go/go.mod diff --git a/examples/go/internal/service/service.go b/agentskill-skill/examples/go/internal/service/service.go similarity index 100% rename from examples/go/internal/service/service.go rename to agentskill-skill/examples/go/internal/service/service.go diff --git a/examples/go/internal/service/service_test.go b/agentskill-skill/examples/go/internal/service/service_test.go similarity index 100% rename from examples/go/internal/service/service_test.go rename to agentskill-skill/examples/go/internal/service/service_test.go diff --git a/examples/java/pom.xml b/agentskill-skill/examples/java/pom.xml similarity index 100% rename from examples/java/pom.xml rename to agentskill-skill/examples/java/pom.xml diff --git a/examples/java/src/main/java/com/example/App.java b/agentskill-skill/examples/java/src/main/java/com/example/App.java similarity index 100% rename from examples/java/src/main/java/com/example/App.java rename to agentskill-skill/examples/java/src/main/java/com/example/App.java diff --git a/examples/java/src/main/java/com/example/service/UserService.java b/agentskill-skill/examples/java/src/main/java/com/example/service/UserService.java similarity index 100% rename from examples/java/src/main/java/com/example/service/UserService.java rename to agentskill-skill/examples/java/src/main/java/com/example/service/UserService.java diff --git a/examples/java/src/test/java/com/example/service/UserServiceTest.java b/agentskill-skill/examples/java/src/test/java/com/example/service/UserServiceTest.java similarity index 100% rename from examples/java/src/test/java/com/example/service/UserServiceTest.java rename to agentskill-skill/examples/java/src/test/java/com/example/service/UserServiceTest.java diff --git a/examples/javascript/package.json b/agentskill-skill/examples/javascript/package.json similarity index 100% rename from examples/javascript/package.json rename to agentskill-skill/examples/javascript/package.json diff --git a/examples/javascript/src/index.js b/agentskill-skill/examples/javascript/src/index.js similarity index 100% rename from examples/javascript/src/index.js rename to agentskill-skill/examples/javascript/src/index.js diff --git a/examples/javascript/src/index.test.js b/agentskill-skill/examples/javascript/src/index.test.js similarity index 100% rename from examples/javascript/src/index.test.js rename to agentskill-skill/examples/javascript/src/index.test.js diff --git a/examples/javascript/src/util.js b/agentskill-skill/examples/javascript/src/util.js similarity index 100% rename from examples/javascript/src/util.js rename to agentskill-skill/examples/javascript/src/util.js diff --git a/examples/kotlin/build.gradle.kts b/agentskill-skill/examples/kotlin/build.gradle.kts similarity index 100% rename from examples/kotlin/build.gradle.kts rename to agentskill-skill/examples/kotlin/build.gradle.kts diff --git a/examples/kotlin/src/main/kotlin/com/example/App.kt b/agentskill-skill/examples/kotlin/src/main/kotlin/com/example/App.kt similarity index 100% rename from examples/kotlin/src/main/kotlin/com/example/App.kt rename to agentskill-skill/examples/kotlin/src/main/kotlin/com/example/App.kt diff --git a/examples/kotlin/src/main/kotlin/com/example/service/UserService.kt b/agentskill-skill/examples/kotlin/src/main/kotlin/com/example/service/UserService.kt similarity index 100% rename from examples/kotlin/src/main/kotlin/com/example/service/UserService.kt rename to agentskill-skill/examples/kotlin/src/main/kotlin/com/example/service/UserService.kt diff --git a/examples/kotlin/src/test/kotlin/com/example/service/UserServiceTest.kt b/agentskill-skill/examples/kotlin/src/test/kotlin/com/example/service/UserServiceTest.kt similarity index 100% rename from examples/kotlin/src/test/kotlin/com/example/service/UserServiceTest.kt rename to agentskill-skill/examples/kotlin/src/test/kotlin/com/example/service/UserServiceTest.kt diff --git a/examples/mixed/README.md b/agentskill-skill/examples/mixed/README.md similarity index 100% rename from examples/mixed/README.md rename to agentskill-skill/examples/mixed/README.md diff --git a/examples/mixed/cmd/app/main.go b/agentskill-skill/examples/mixed/cmd/app/main.go similarity index 100% rename from examples/mixed/cmd/app/main.go rename to agentskill-skill/examples/mixed/cmd/app/main.go diff --git a/examples/mixed/go.mod b/agentskill-skill/examples/mixed/go.mod similarity index 100% rename from examples/mixed/go.mod rename to agentskill-skill/examples/mixed/go.mod diff --git a/examples/mixed/package.json b/agentskill-skill/examples/mixed/package.json similarity index 100% rename from examples/mixed/package.json rename to agentskill-skill/examples/mixed/package.json diff --git a/examples/mixed/scripts/deploy.sh b/agentskill-skill/examples/mixed/scripts/deploy.sh similarity index 100% rename from examples/mixed/scripts/deploy.sh rename to agentskill-skill/examples/mixed/scripts/deploy.sh diff --git a/examples/mixed/src/index.ts b/agentskill-skill/examples/mixed/src/index.ts similarity index 100% rename from examples/mixed/src/index.ts rename to agentskill-skill/examples/mixed/src/index.ts diff --git a/examples/mixed/tsconfig.json b/agentskill-skill/examples/mixed/tsconfig.json similarity index 100% rename from examples/mixed/tsconfig.json rename to agentskill-skill/examples/mixed/tsconfig.json diff --git a/examples/objectivec/Podfile b/agentskill-skill/examples/objectivec/Podfile similarity index 100% rename from examples/objectivec/Podfile rename to agentskill-skill/examples/objectivec/Podfile diff --git a/examples/objectivec/Sources/UserService.h b/agentskill-skill/examples/objectivec/Sources/UserService.h similarity index 100% rename from examples/objectivec/Sources/UserService.h rename to agentskill-skill/examples/objectivec/Sources/UserService.h diff --git a/examples/objectivec/Sources/UserService.m b/agentskill-skill/examples/objectivec/Sources/UserService.m similarity index 100% rename from examples/objectivec/Sources/UserService.m rename to agentskill-skill/examples/objectivec/Sources/UserService.m diff --git a/examples/objectivec/Tests/UserServiceTests.m b/agentskill-skill/examples/objectivec/Tests/UserServiceTests.m similarity index 100% rename from examples/objectivec/Tests/UserServiceTests.m rename to agentskill-skill/examples/objectivec/Tests/UserServiceTests.m diff --git a/examples/php/composer.json b/agentskill-skill/examples/php/composer.json similarity index 100% rename from examples/php/composer.json rename to agentskill-skill/examples/php/composer.json diff --git a/examples/php/src/Repository/UserRepository.php b/agentskill-skill/examples/php/src/Repository/UserRepository.php similarity index 100% rename from examples/php/src/Repository/UserRepository.php rename to agentskill-skill/examples/php/src/Repository/UserRepository.php diff --git a/examples/php/src/Service/UserService.php b/agentskill-skill/examples/php/src/Service/UserService.php similarity index 100% rename from examples/php/src/Service/UserService.php rename to agentskill-skill/examples/php/src/Service/UserService.php diff --git a/examples/php/tests/Service/UserServiceTest.php b/agentskill-skill/examples/php/tests/Service/UserServiceTest.php similarity index 100% rename from examples/php/tests/Service/UserServiceTest.php rename to agentskill-skill/examples/php/tests/Service/UserServiceTest.php diff --git a/examples/python/pyproject.toml b/agentskill-skill/examples/python/pyproject.toml similarity index 100% rename from examples/python/pyproject.toml rename to agentskill-skill/examples/python/pyproject.toml diff --git a/examples/python/src/app.py b/agentskill-skill/examples/python/src/app.py similarity index 100% rename from examples/python/src/app.py rename to agentskill-skill/examples/python/src/app.py diff --git a/examples/python/src/util.py b/agentskill-skill/examples/python/src/util.py similarity index 100% rename from examples/python/src/util.py rename to agentskill-skill/examples/python/src/util.py diff --git a/examples/python/tests/test_app.py b/agentskill-skill/examples/python/tests/test_app.py similarity index 100% rename from examples/python/tests/test_app.py rename to agentskill-skill/examples/python/tests/test_app.py diff --git a/examples/ruby/Gemfile b/agentskill-skill/examples/ruby/Gemfile similarity index 100% rename from examples/ruby/Gemfile rename to agentskill-skill/examples/ruby/Gemfile diff --git a/examples/ruby/lib/example/helper.rb b/agentskill-skill/examples/ruby/lib/example/helper.rb similarity index 100% rename from examples/ruby/lib/example/helper.rb rename to agentskill-skill/examples/ruby/lib/example/helper.rb diff --git a/examples/ruby/lib/example/service.rb b/agentskill-skill/examples/ruby/lib/example/service.rb similarity index 100% rename from examples/ruby/lib/example/service.rb rename to agentskill-skill/examples/ruby/lib/example/service.rb diff --git a/examples/ruby/spec/service_spec.rb b/agentskill-skill/examples/ruby/spec/service_spec.rb similarity index 100% rename from examples/ruby/spec/service_spec.rb rename to agentskill-skill/examples/ruby/spec/service_spec.rb diff --git a/examples/rust/Cargo.toml b/agentskill-skill/examples/rust/Cargo.toml similarity index 100% rename from examples/rust/Cargo.toml rename to agentskill-skill/examples/rust/Cargo.toml diff --git a/examples/rust/clippy.toml b/agentskill-skill/examples/rust/clippy.toml similarity index 100% rename from examples/rust/clippy.toml rename to agentskill-skill/examples/rust/clippy.toml diff --git a/examples/rust/rustfmt.toml b/agentskill-skill/examples/rust/rustfmt.toml similarity index 100% rename from examples/rust/rustfmt.toml rename to agentskill-skill/examples/rust/rustfmt.toml diff --git a/examples/rust/src/lib.rs b/agentskill-skill/examples/rust/src/lib.rs similarity index 100% rename from examples/rust/src/lib.rs rename to agentskill-skill/examples/rust/src/lib.rs diff --git a/examples/rust/src/parser.rs b/agentskill-skill/examples/rust/src/parser.rs similarity index 100% rename from examples/rust/src/parser.rs rename to agentskill-skill/examples/rust/src/parser.rs diff --git a/examples/rust/tests/parser_test.rs b/agentskill-skill/examples/rust/tests/parser_test.rs similarity index 100% rename from examples/rust/tests/parser_test.rs rename to agentskill-skill/examples/rust/tests/parser_test.rs diff --git a/examples/swift/Package.swift b/agentskill-skill/examples/swift/Package.swift similarity index 100% rename from examples/swift/Package.swift rename to agentskill-skill/examples/swift/Package.swift diff --git a/examples/swift/Sources/MyApp/App.swift b/agentskill-skill/examples/swift/Sources/MyApp/App.swift similarity index 100% rename from examples/swift/Sources/MyApp/App.swift rename to agentskill-skill/examples/swift/Sources/MyApp/App.swift diff --git a/examples/swift/Sources/MyAppCore/UserService.swift b/agentskill-skill/examples/swift/Sources/MyAppCore/UserService.swift similarity index 100% rename from examples/swift/Sources/MyAppCore/UserService.swift rename to agentskill-skill/examples/swift/Sources/MyAppCore/UserService.swift diff --git a/examples/swift/Tests/MyAppTests/UserServiceTests.swift b/agentskill-skill/examples/swift/Tests/MyAppTests/UserServiceTests.swift similarity index 100% rename from examples/swift/Tests/MyAppTests/UserServiceTests.swift rename to agentskill-skill/examples/swift/Tests/MyAppTests/UserServiceTests.swift diff --git a/examples/typescript/package.json b/agentskill-skill/examples/typescript/package.json similarity index 100% rename from examples/typescript/package.json rename to agentskill-skill/examples/typescript/package.json diff --git a/examples/typescript/src/index.ts b/agentskill-skill/examples/typescript/src/index.ts similarity index 100% rename from examples/typescript/src/index.ts rename to agentskill-skill/examples/typescript/src/index.ts diff --git a/examples/typescript/src/user.test.ts b/agentskill-skill/examples/typescript/src/user.test.ts similarity index 100% rename from examples/typescript/src/user.test.ts rename to agentskill-skill/examples/typescript/src/user.test.ts diff --git a/examples/typescript/src/user.ts b/agentskill-skill/examples/typescript/src/user.ts similarity index 100% rename from examples/typescript/src/user.ts rename to agentskill-skill/examples/typescript/src/user.ts diff --git a/examples/typescript/tsconfig.json b/agentskill-skill/examples/typescript/tsconfig.json similarity index 100% rename from examples/typescript/tsconfig.json rename to agentskill-skill/examples/typescript/tsconfig.json diff --git a/references/GOTCHAS.md b/agentskill-skill/references/GOTCHAS.md similarity index 91% rename from references/GOTCHAS.md rename to agentskill-skill/references/GOTCHAS.md index 2fccee0..cfe2b5f 100644 --- a/references/GOTCHAS.md +++ b/agentskill-skill/references/GOTCHAS.md @@ -12,7 +12,7 @@ These errors occur during data collection — the signal is wrong before synthes --- -### Keyword pollution +### Keyword Pollution **What happens:** Language keywords (`self`, `cls`, `if`, `for`, `return`) are counted alongside identifier names, inflating snake_case totals and polluting naming pattern analysis. @@ -20,7 +20,7 @@ These errors occur during data collection — the signal is wrong before synthes --- -### Single-word name ambiguity +### Single-Word Name Ambiguity **What happens:** A name like `foo` or `data` matches both `camelCase` and `snake_case` classifiers because it has no case transitions. These names dominate short codebases and produce false confidence. @@ -28,7 +28,7 @@ These errors occur during data collection — the signal is wrong before synthes --- -### Generated file skew +### Generated File Skew **What happens:** Vendored files, lockfiles, and generated code have zero comments, uniform indentation, and no meaningful names. Including them distorts every measurement. @@ -36,7 +36,7 @@ These errors occur during data collection — the signal is wrong before synthes --- -### Test file bias +### Test File Bias **What happens:** Test files use different idioms than source files — more `assert` statements, more fixture variables, more repetitive naming. Mixing them into source analysis contaminates naming and error handling measurements. @@ -44,7 +44,7 @@ These errors occur during data collection — the signal is wrong before synthes --- -### Blank line measurement at file boundaries +### Blank Line Measurement At File Boundaries **What happens:** The first top-level definition in a file has no predecessor, so the blank line count before it is always zero. Including this in the distribution pulls the mode toward zero even when the real convention is two blank lines between definitions. @@ -52,7 +52,7 @@ These errors occur during data collection — the signal is wrong before synthes --- -### Import misclassification +### Import Misclassification **What happens:** stdlib module names that overlap with third-party package names (`email`, `ast`, `typing`) get classified as third-party, and vice versa. This produces incorrect import ordering rules. @@ -60,7 +60,7 @@ These errors occur during data collection — the signal is wrong before synthes --- -### Branch inflation from remote tracking refs +### Branch Inflation From Remote Tracking Refs **What happens:** `git branch -a` returns both local branches and remote tracking refs (`remotes/origin/fix/thing`). Counting both doubles the apparent branch count and inflates prefix diversity. @@ -68,7 +68,7 @@ These errors occur during data collection — the signal is wrong before synthes --- -### Monorepo boundary misdetection +### Monorepo Boundary Misdetection **What happens:** A repo with a `packages/` or `services/` directory at the root is treated as a monorepo even when it contains a single service. This triggers Section 3 and Section 4 synthesis when they don't apply. @@ -82,7 +82,7 @@ These errors occur during `AGENTS.md` generation — the data is fine but the ou --- -### Reporting language defaults as codebase rules +### Reporting Language Defaults As Codebase Rules **What happens:** `AGENTS.md` states "uses snake_case for variable names in Python" or "uses tabs in Go." These are language defaults, not codebase conventions. An agent following these rules learns nothing specific about this repo. @@ -90,7 +90,7 @@ These errors occur during `AGENTS.md` generation — the data is fine but the ou --- -### Claiming formatter use without explicit config +### Claiming Formatter Use Without Explicit Config **What happens:** The code looks formatted, so `AGENTS.md` states "uses Black" or "uses Prettier." But no config file exists, and the author may simply write clean code manually. An agent that believes a formatter is in use may generate sloppy code expecting a post-processing fix. @@ -98,7 +98,7 @@ These errors occur during `AGENTS.md` generation — the data is fine but the ou --- -### Universal noise in red lines +### Universal Noise In Red Lines **What happens:** Red lines include entries like "be consistent with naming" or "handle errors properly." These apply to every codebase and teach an agent nothing about this one. @@ -106,7 +106,7 @@ These errors occur during `AGENTS.md` generation — the data is fine but the ou --- -### Omitting the Code Formatting section or treating it as lower priority +### Omitting The Code Formatting Section Or Treating It As Lower Priority **What happens:** Synthesis focuses on naming and error handling — the interesting sections — and produces a thin or empty Code Formatting section. The agent then generates code with wrong indentation, wrong blank lines, or wrong quote style. @@ -114,7 +114,7 @@ These errors occur during `AGENTS.md` generation — the data is fine but the ou --- -### Conflating method blank lines with top-level blank lines +### Conflating Method Blank Lines With Top-Level Blank Lines **What happens:** The measured blank line convention (e.g. two blank lines between top-level definitions) is incorrectly applied to methods inside classes, where the convention may be one blank line. Or the reverse. @@ -122,7 +122,7 @@ These errors occur during `AGENTS.md` generation — the data is fine but the ou --- -### Missing metadata from README and LICENSE +### Missing Metadata From README And LICENSE **What happens:** `AGENTS.md` is written without checking `README.md` or `LICENSE`. The overview section omits the project's stated purpose, and the dependencies section omits the license type, which sometimes affects dependency philosophy. @@ -130,7 +130,7 @@ These errors occur during `AGENTS.md` generation — the data is fine but the ou --- -### Carrying rules across language boundaries without labeling them +### Carrying Rules Across Language Boundaries Without Labeling Them **What happens:** A Python naming rule or a TypeScript error handling pattern ends up in a shared or unlabeled section, and an agent working in Go applies it. @@ -138,7 +138,7 @@ These errors occur during `AGENTS.md` generation — the data is fine but the ou --- -### Stale remote assumptions +### Stale Remote Assumptions **What happens:** A GitHub remote is detected, so `AGENTS.md` states CI exists. But the `.github/workflows/` directory is empty or absent. @@ -146,7 +146,7 @@ These errors occur during `AGENTS.md` generation — the data is fine but the ou --- -### Tentative rules left unlabeled +### Tentative Rules Left Unlabeled **What happens:** A pattern is observed in only one or two files but stated as a firm rule. An agent follows it without knowing the evidence is thin. @@ -154,7 +154,7 @@ These errors occur during `AGENTS.md` generation — the data is fine but the ou --- -### Metric-only qualitative sections +### Metric-Only Qualitative Sections **What happens:** A section like Error Handling or Comments and Docstrings is generated from analyzer counts such as "12 `except` blocks observed" or "34 comments found." The output is technically derived from the repo, but it does not teach an agent what code to write. diff --git a/.specs/.gitkeep b/agentskill-specs/.gitkeep similarity index 100% rename from .specs/.gitkeep rename to agentskill-specs/.gitkeep diff --git a/tests/contracts/analyze_mixed.json b/agentskill-tests/contracts/analyze_mixed.json similarity index 100% rename from tests/contracts/analyze_mixed.json rename to agentskill-tests/contracts/analyze_mixed.json diff --git a/tests/contracts/analyze_python.json b/agentskill-tests/contracts/analyze_python.json similarity index 100% rename from tests/contracts/analyze_python.json rename to agentskill-tests/contracts/analyze_python.json diff --git a/tests/contracts/config_mixed.json b/agentskill-tests/contracts/config_mixed.json similarity index 100% rename from tests/contracts/config_mixed.json rename to agentskill-tests/contracts/config_mixed.json diff --git a/tests/contracts/graph_mixed.json b/agentskill-tests/contracts/graph_mixed.json similarity index 100% rename from tests/contracts/graph_mixed.json rename to agentskill-tests/contracts/graph_mixed.json diff --git a/tests/contracts/scan_python.json b/agentskill-tests/contracts/scan_python.json similarity index 100% rename from tests/contracts/scan_python.json rename to agentskill-tests/contracts/scan_python.json diff --git a/tests/contracts/symbols_python.json b/agentskill-tests/contracts/symbols_python.json similarity index 100% rename from tests/contracts/symbols_python.json rename to agentskill-tests/contracts/symbols_python.json diff --git a/tests/fixtures/go/golangci.yml b/agentskill-tests/fixtures/go/golangci.yml similarity index 100% rename from tests/fixtures/go/golangci.yml rename to agentskill-tests/fixtures/go/golangci.yml diff --git a/tests/fixtures/js/eslint.yaml b/agentskill-tests/fixtures/js/eslint.yaml similarity index 100% rename from tests/fixtures/js/eslint.yaml rename to agentskill-tests/fixtures/js/eslint.yaml diff --git a/tests/fixtures/js/prettier.yaml b/agentskill-tests/fixtures/js/prettier.yaml similarity index 100% rename from tests/fixtures/js/prettier.yaml rename to agentskill-tests/fixtures/js/prettier.yaml diff --git a/tests/fixtures/python/pyproject.toml b/agentskill-tests/fixtures/python/pyproject.toml similarity index 100% rename from tests/fixtures/python/pyproject.toml rename to agentskill-tests/fixtures/python/pyproject.toml diff --git a/tests/fixtures/rust/clippy.toml b/agentskill-tests/fixtures/rust/clippy.toml similarity index 100% rename from tests/fixtures/rust/clippy.toml rename to agentskill-tests/fixtures/rust/clippy.toml diff --git a/tests/fixtures/rust/rustfmt.toml b/agentskill-tests/fixtures/rust/rustfmt.toml similarity index 100% rename from tests/fixtures/rust/rustfmt.toml rename to agentskill-tests/fixtures/rust/rustfmt.toml diff --git a/agentskill/Cargo.toml b/agentskill/Cargo.toml new file mode 100644 index 0000000..cda5231 --- /dev/null +++ b/agentskill/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "agentskill" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +description = "Analyze repositories and synthesize AGENTS.md" + +[[bin]] +name = "agentskill" +path = "src/main.rs" + +[[bin]] +name = "agsk" +path = "src/bin/agsk.rs" + +[dependencies] +agentskill-analyzers.workspace = true +agentskill-core.workspace = true +agentskill-generation.workspace = true +clap.workspace = true + +[dev-dependencies] +serde_json.workspace = true +tempfile.workspace = true diff --git a/agentskill/__init__.py b/agentskill/__init__.py deleted file mode 100644 index da0e153..0000000 --- a/agentskill/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Packaged CLI entrypoint namespace for agentskill.""" diff --git a/agentskill/commands/__init__.py b/agentskill/commands/__init__.py deleted file mode 100644 index 2bdf472..0000000 --- a/agentskill/commands/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Command implementations for agentskill analyzers.""" diff --git a/agentskill/commands/config.py b/agentskill/commands/config.py deleted file mode 100644 index 2a0e4d3..0000000 --- a/agentskill/commands/config.py +++ /dev/null @@ -1,751 +0,0 @@ -#!/usr/bin/env python3 -"""Detect formatters, linters, and type checkers. Extract their exact settings. - -Reads config files directly — does not infer from code style. -Covers Python, TypeScript/JavaScript, Go, Rust. Falls back to .editorconfig -for cross-language indentation/line-ending settings. - -Usage: - python scripts/config.py - python scripts/config.py --pretty -""" - -import json -import re -import sys -from pathlib import Path -from typing import Any - -from agentskill.common.fs import validate_repo -from agentskill.lib.cli_entrypoint import run_command_main -from agentskill.lib.parsers import load_toml_safe, load_yaml_safe - -MAX_CONFIG_READ_BYTES = 32_000 - -PRETTIER_CONFIG_FILES = [ - ".prettierrc", - ".prettierrc.json", - ".prettierrc.js", - ".prettierrc.cjs", - ".prettierrc.yml", - ".prettierrc.yaml", - ".prettierrc.toml", -] - -ESLINT_CONFIG_FILES = [ - ".eslintrc.json", - ".eslintrc.js", - ".eslintrc.cjs", - ".eslintrc.yml", - ".eslintrc.yaml", - ".eslintrc", - "eslint.config.js", - "eslint.config.mjs", - "eslint.config.cjs", -] - - -def _get_nested(d: Any, *keys: str) -> Any | None: - for k in keys: - if not isinstance(d, dict): - return None - - d = d.get(k) - - return d - - -def _parse_by_extension(raw: str, fname: str) -> dict: - """Parse raw config file content based on file extension.""" - if fname.endswith(".json") or fname in (".prettierrc", ".eslintrc"): - return _parse_json_safe(raw) if raw.strip().startswith("{") else {} - - if fname.endswith((".yml", ".yaml")): - return load_yaml_safe(raw) - - if fname.endswith(".toml"): - return load_toml_safe(raw) - - return {} - - -def _read(path: Path, max_bytes: int = MAX_CONFIG_READ_BYTES) -> str: - try: - return path.read_text(errors="ignore")[:max_bytes] - except Exception: - return "" - - -def _parse_json_safe(content: str) -> dict: - try: - return json.loads(content) - except Exception: - return {} - - -def _parse_ini_section(content: str, header: str) -> dict: - pattern = re.compile( - r"^" + re.escape(header) + r"(.*?)(?=^\[|\Z)", - re.MULTILINE | re.DOTALL, - ) - - m = pattern.search(content) - - if not m: - return {} - - result = {} - - for line in m.group(1).splitlines(): - line = line.strip() - - if not line or line.startswith("#") or line.startswith(";"): - continue - - if "=" in line: - k, _, v = line.partition("=") - result[k.strip()] = v.strip() - - return result - - -def _parse_editorconfig(path: Path) -> dict: - content = _read(path) - sections: dict[str, dict] = {} - current: dict = {} - header = None - - for line in content.splitlines(): - s = line.strip() - - if not s or s.startswith("#") or s.startswith(";"): - continue - - if s.startswith("["): - if header and current: - sections[header] = current - - header = s - current = {} - elif "=" in s: - k, _, v = s.partition("=") - current[k.strip().lower()] = v.strip().lower() - - if header and current: - sections[header] = current - - return sections - - -def _parse_editorconfig_for_lang(sections: dict, lang: str) -> dict: - """Extract editorconfig rules relevant to a language.""" - lang_ext_map = { - "python": ["*.py"], - "typescript": ["*.ts", "*.tsx"], - "javascript": ["*.js", "*.jsx", "*.mjs"], - "go": ["*.go"], - "rust": ["*.rs"], - "java": ["*.java"], - "kotlin": ["*.kt", "*.kts"], - "csharp": ["*.cs"], - "c": ["*.c", "*.h"], - "cpp": ["*.cpp", "*.cc", "*.cxx", "*.hpp", "*.hh", "*.hxx"], - "ruby": ["*.rb"], - "php": ["*.php"], - "bash": ["*.sh", "*.bash"], - "swift": ["*.swift"], - "objectivec": ["*.m", "*.mm", "*.h"], - } - - exts = lang_ext_map.get(lang, []) - result = dict(sections.get("[*]", {})) - - for ext in exts: - result.update(sections.get(f"[{ext}]", {})) - - return result - - -def _detect_python_formatter(repo: Path, toml_data: dict) -> dict | None: - ruff_format = _get_nested(toml_data, "tool", "ruff", "format") - ruff_top = _get_nested(toml_data, "tool", "ruff") or {} - black_cfg = _get_nested(toml_data, "tool", "black") - ruff_toml = repo / "ruff.toml" - - if ruff_format or ruff_toml.exists(): - settings = dict(ruff_format) if isinstance(ruff_format, dict) else {} - - for key in ("line-length", "indent-width"): - if key in ruff_top: - settings.setdefault(key, ruff_top[key]) - - if ruff_toml.exists(): - extra = load_toml_safe(_read(ruff_toml)) - settings.update(extra.get("format", {})) - - if "line-length" in extra: - settings.setdefault("line-length", extra["line-length"]) - - return {"name": "ruff", "config_file": "pyproject.toml", "settings": settings} - - if black_cfg or (repo / ".black").exists(): - settings = dict(black_cfg) if isinstance(black_cfg, dict) else {} - return {"name": "black", "config_file": "pyproject.toml", "settings": settings} - - black_toml = repo / "black.toml" - - if black_toml.exists(): - return { - "name": "black", - "config_file": "black.toml", - "settings": load_toml_safe(_read(black_toml)), - } - - return None - - -def _detect_python_linter(repo: Path, toml_data: dict) -> dict | None: - ruff_lint = _get_nested(toml_data, "tool", "ruff", "lint") - ruff_top_cfg = _get_nested(toml_data, "tool", "ruff") or {} - flake8_cfg = repo / ".flake8" - setup_cfg = repo / "setup.cfg" - - if ruff_lint or ruff_top_cfg: - settings = dict(ruff_lint) if isinstance(ruff_lint, dict) else {} - - for k in ( - "select", - "ignore", - "extend-select", - "extend-ignore", - "per-file-ignores", - ): - if k in ruff_top_cfg and k not in settings: - settings[k] = ruff_top_cfg[k] - - return {"name": "ruff", "config_file": "pyproject.toml", "settings": settings} - - if flake8_cfg.exists(): - return { - "name": "flake8", - "config_file": ".flake8", - "settings": _parse_ini_section(_read(flake8_cfg), "[flake8]"), - } - - if setup_cfg.exists(): - parsed = _parse_ini_section(_read(setup_cfg), "[flake8]") - - if parsed: - return {"name": "flake8", "config_file": "setup.cfg", "settings": parsed} - - return None - - -def _detect_python_type_checker(repo: Path, toml_data: dict) -> dict | None: - mypy_cfg = _get_nested(toml_data, "tool", "mypy") - mypy_ini = repo / "mypy.ini" - mypy_ini2 = repo / ".mypy.ini" - pyright_json = repo / "pyrightconfig.json" - - if mypy_cfg: - return { - "name": "mypy", - "config_file": "pyproject.toml", - "settings": dict(mypy_cfg), - } - - if mypy_ini.exists(): - return { - "name": "mypy", - "config_file": "mypy.ini", - "settings": _parse_ini_section(_read(mypy_ini), "[mypy]"), - } - - if mypy_ini2.exists(): - return { - "name": "mypy", - "config_file": ".mypy.ini", - "settings": _parse_ini_section(_read(mypy_ini2), "[mypy]"), - } - - if pyright_json.exists(): - return { - "name": "pyright", - "config_file": "pyrightconfig.json", - "settings": _parse_json_safe(_read(pyright_json)), - } - - return None - - -def _detect_python(repo: Path, toml_data: dict) -> dict: - result: dict = {} - - formatter = _detect_python_formatter(repo, toml_data) - linter = _detect_python_linter(repo, toml_data) - type_checker = _detect_python_type_checker(repo, toml_data) - - if formatter: - result["formatter"] = formatter - - if linter: - result["linter"] = linter - - if type_checker: - result["type_checker"] = type_checker - - return result - - -def _detect_typescript(repo: Path) -> dict: - result: dict = {} - - for fname in PRETTIER_CONFIG_FILES: - fp = repo / fname - - if fp.exists(): - result["formatter"] = { - "name": "prettier", - "config_file": fname, - "settings": _parse_by_extension(_read(fp), fname), - } - - break - - if "formatter" not in result: - pkg = repo / "package.json" - - if pkg.exists(): - data = _parse_json_safe(_read(pkg)) - - if "prettier" in data: - result["formatter"] = { - "name": "prettier", - "config_file": "package.json", - "settings": data["prettier"], - } - - for fname in ESLINT_CONFIG_FILES: - fp = repo / fname - - if fp.exists(): - result["linter"] = { - "name": "eslint", - "config_file": fname, - "settings": _parse_by_extension(_read(fp), fname), - } - - break - - tsconfig = repo / "tsconfig.json" - - if tsconfig.exists(): - data = _parse_json_safe(_read(tsconfig)) - - result["type_checker"] = { - "name": "tsc", - "config_file": "tsconfig.json", - "settings": data.get("compilerOptions", {}), - } - - return result - - -def _detect_go(repo: Path) -> dict: - result: dict = { - "formatter": {"name": "gofmt", "config_file": None, "settings": {}}, - } - - for fname in [ - ".golangci.yml", - ".golangci.yaml", - ".golangci.toml", - ".golangci.json", - ]: - fp = repo / fname - - if fp.exists(): - result["linter"] = { - "name": "golangci-lint", - "config_file": fname, - "settings": _parse_by_extension(_read(fp), fname), - } - - break - - return result - - -def _detect_rust(repo: Path) -> dict: - result: dict = {} - - for fname in ["rustfmt.toml", ".rustfmt.toml"]: - fp = repo / fname - - if fp.exists(): - result["formatter"] = { - "name": "rustfmt", - "config_file": fname, - "settings": load_toml_safe(_read(fp)), - } - - break - - clippy = repo / "clippy.toml" - - if clippy.exists(): - result["linter"] = { - "name": "clippy", - "config_file": "clippy.toml", - "settings": load_toml_safe(_read(clippy)), - } - - return result - - -def _detect_jvm_markers(repo: Path, language: str) -> dict: - markers: list[str] = [] - build_files = { - "java": [ - "pom.xml", - "build.gradle", - "build.gradle.kts", - "settings.gradle", - "settings.gradle.kts", - ], - "kotlin": [ - "build.gradle", - "build.gradle.kts", - "settings.gradle", - "settings.gradle.kts", - ], - } - - source_roots = { - "java": ["src/main/java", "src/test/java"], - "kotlin": ["src/main/kotlin", "src/test/kotlin"], - } - - for marker in build_files.get(language, []): - if (repo / marker).exists(): - markers.append(marker) - - for root in source_roots.get(language, []): - if (repo / root).exists(): - markers.append(root) - - if not markers: - return {} - - build_tool = None - - if language == "java" and "pom.xml" in markers: - build_tool = "maven" - elif any(marker.startswith("build.gradle") for marker in markers) or any( - marker.startswith("settings.gradle") for marker in markers - ): - build_tool = "gradle" - - return { - "project_markers": sorted(markers), - "build_tool": build_tool, - } - - -def _detect_csharp_markers(repo: Path) -> dict: - markers: list[str] = [] - - for pattern in ( - "*.csproj", - "*.sln", - "Directory.Build.props", - "Directory.Build.targets", - ): - if "*" in pattern: - markers.extend(sorted(path.name for path in repo.rglob(pattern))) - elif (repo / pattern).exists(): - markers.append(pattern) - - if not markers: - return {} - - return { - "project_markers": sorted(set(markers)), - "build_tool": "msbuild", - } - - -def _detect_c_family_markers(repo: Path, language: str) -> dict: - markers: list[str] = [] - - for marker in ("CMakeLists.txt", "Makefile", "makefile", "GNUmakefile"): - if (repo / marker).exists(): - markers.append(marker) - - cmake_files = sorted(path.name for path in repo.rglob("*.cmake")) - - if cmake_files: - markers.extend(cmake_files) - - vcxproj_files = sorted(path.name for path in repo.rglob("*.vcxproj")) - - if vcxproj_files: - markers.extend(vcxproj_files) - - if not markers: - return {} - - build_tool = None - - if "CMakeLists.txt" in markers or cmake_files: - build_tool = "cmake" - elif any(marker in markers for marker in ("Makefile", "makefile", "GNUmakefile")): - build_tool = "make" - - return { - "project_markers": sorted(set(markers)), - "build_tool": build_tool, - } - - -def _detect_ruby_markers(repo: Path) -> dict: - markers: list[str] = [] - - for marker in ("Gemfile", "Gemfile.lock"): - if (repo / marker).exists(): - markers.append(marker) - - markers.extend(sorted(path.name for path in repo.rglob("*.gemspec"))) - - if not markers: - return {} - - return { - "project_markers": sorted(set(markers)), - "build_tool": "bundler" if "Gemfile" in markers else None, - } - - -def _detect_php_markers(repo: Path) -> dict: - markers: list[str] = [] - - for marker in ("composer.json", "composer.lock"): - if (repo / marker).exists(): - markers.append(marker) - - if not markers: - return {} - - result: dict = { - "project_markers": sorted(markers), - "build_tool": "composer", - } - - composer = repo / "composer.json" - - if composer.exists(): - data = _parse_json_safe(_read(composer)) - autoload = data.get("autoload", {}) - autoload_dev = data.get("autoload-dev", {}) - require_dev = data.get("require-dev", {}) - - if autoload.get("psr-4"): - result["autoload_psr4"] = autoload["psr-4"] - - if autoload_dev.get("psr-4"): - result["autoload_dev_psr4"] = autoload_dev["psr-4"] - - if "phpunit/phpunit" in require_dev: - result["test_framework"] = "phpunit" - - return result - - -def _detect_apple_markers(repo: Path, language: str) -> dict: - markers: list[str] = [] - - if language == "swift": - static_markers = ["Package.swift", "Package.resolved"] - else: - static_markers = ["Podfile", "Podfile.lock"] - - for marker in static_markers: - if (repo / marker).exists(): - markers.append(marker) - - markers.extend(sorted(path.name for path in repo.rglob("*.xcodeproj"))) - markers.extend(sorted(path.name for path in repo.rglob("*.xcworkspace"))) - - if not markers: - return {} - - build_tool = None - - if language == "swift": - if "Package.swift" in markers: - build_tool = "swiftpm" - elif any(marker.endswith((".xcodeproj", ".xcworkspace")) for marker in markers): - build_tool = "xcode" - else: - if "Podfile" in markers or "Podfile.lock" in markers: - build_tool = "cocoapods" - elif any(marker.endswith((".xcodeproj", ".xcworkspace")) for marker in markers): - build_tool = "xcode" - - return { - "project_markers": sorted(set(markers)), - "build_tool": build_tool, - } - - -def _attach_editorconfig(lang_result: dict, ec_sections: dict, lang: str) -> None: - """Mutate lang_result in place: attach editorconfig entry if one exists.""" - ec = _parse_editorconfig_for_lang(ec_sections, lang) - - if ec: - lang_result["editorconfig"] = ec - - -def _has_matching_files(repo: Path, patterns: tuple[str, ...]) -> bool: - return any(next(repo.rglob(pattern), None) is not None for pattern in patterns) - - -def detect(repo_path: str) -> dict: - try: - repo = validate_repo(repo_path) - except ValueError as exc: - return {"error": str(exc), "script": "config"} - - toml_data: dict = {} - pyproject = repo / "pyproject.toml" - - if pyproject.exists(): - toml_data = load_toml_safe(_read(pyproject)) - - ec_sections: dict = {} - ec = repo / ".editorconfig" - - if ec.exists(): - ec_sections = _parse_editorconfig(ec) - - result: dict = {} - py = _detect_python(repo, toml_data) - - if py: - _attach_editorconfig(py, ec_sections, "python") - result["python"] = py - - ts = _detect_typescript(repo) - has_typescript = ( - _has_matching_files(repo, ("*.ts", "*.tsx")) - or (repo / "tsconfig.json").exists() - ) - - has_javascript = ( - _has_matching_files(repo, ("*.js", "*.jsx", "*.mjs", "*.cjs")) - or (repo / "jsconfig.json").exists() - ) - - if ts and has_typescript: - typescript = dict(ts) - _attach_editorconfig(typescript, ec_sections, "typescript") - result["typescript"] = typescript - - if ts and has_javascript: - javascript = dict(ts) - _attach_editorconfig(javascript, ec_sections, "javascript") - result["javascript"] = javascript - - if ts and not has_typescript and not has_javascript: - typescript = dict(ts) - _attach_editorconfig(typescript, ec_sections, "typescript") - result["typescript"] = typescript - - go = _detect_go(repo) - - if (repo / "go.mod").exists() or list(repo.rglob("*.go")): - _attach_editorconfig(go, ec_sections, "go") - result["go"] = go - - rust = _detect_rust(repo) - - if (repo / "Cargo.toml").exists() or list(repo.rglob("*.rs")): - _attach_editorconfig(rust, ec_sections, "rust") - result["rust"] = rust - - java = _detect_jvm_markers(repo, "java") - - if java or list(repo.rglob("*.java")): - _attach_editorconfig(java, ec_sections, "java") - result["java"] = java - - kotlin = _detect_jvm_markers(repo, "kotlin") - - if kotlin or list(repo.rglob("*.kt")) or list(repo.rglob("*.kts")): - _attach_editorconfig(kotlin, ec_sections, "kotlin") - result["kotlin"] = kotlin - - csharp = _detect_csharp_markers(repo) - - if csharp or list(repo.rglob("*.cs")): - _attach_editorconfig(csharp, ec_sections, "csharp") - result["csharp"] = csharp - - c_lang = _detect_c_family_markers(repo, "c") - - if c_lang or list(repo.rglob("*.c")) or list(repo.rglob("*.h")): - _attach_editorconfig(c_lang, ec_sections, "c") - result["c"] = c_lang - - cpp = _detect_c_family_markers(repo, "cpp") - - if ( - cpp - or list(repo.rglob("*.cpp")) - or list(repo.rglob("*.cc")) - or list(repo.rglob("*.cxx")) - ): - _attach_editorconfig(cpp, ec_sections, "cpp") - result["cpp"] = cpp - - ruby = _detect_ruby_markers(repo) - - if ruby or list(repo.rglob("*.rb")): - _attach_editorconfig(ruby, ec_sections, "ruby") - result["ruby"] = ruby - - php = _detect_php_markers(repo) - - if php or list(repo.rglob("*.php")): - _attach_editorconfig(php, ec_sections, "php") - result["php"] = php - - swift = _detect_apple_markers(repo, "swift") - - if swift or list(repo.rglob("*.swift")): - _attach_editorconfig(swift, ec_sections, "swift") - result["swift"] = swift - - objectivec = _detect_apple_markers(repo, "objectivec") - - if objectivec or list(repo.rglob("*.m")) or list(repo.rglob("*.mm")): - _attach_editorconfig(objectivec, ec_sections, "objectivec") - result["objectivec"] = objectivec - - if ec_sections: - result["editorconfig"] = ec_sections - - return result - - -def main(argv: list[str] | None = None) -> int: - return run_command_main( - argv=argv, - description=__doc__, - command_fn=detect, - script_name="config", - ) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/agentskill/commands/git.py b/agentskill/commands/git.py deleted file mode 100644 index 516b873..0000000 --- a/agentskill/commands/git.py +++ /dev/null @@ -1,321 +0,0 @@ -#!/usr/bin/env python3 -"""Analyze the git commit log. Extract commit conventions, branch patterns, merge strategy. - -Reads actual history — never infers from config or documentation. - -Usage: - python scripts/git.py - python scripts/git.py --pretty -""" - -import re -import subprocess -import sys - -from agentskill.common.fs import validate_repo -from agentskill.lib.cli_entrypoint import run_command_main -from agentskill.lib.logging_utils import get_logger - -GIT_TIMEOUT = 30 -GIT_HASH_LENGTH = 40 -MAX_SCOPE_EXAMPLES = 10 -MERGE_COMMITS_SAMPLE = 50 -SQUASH_PARENT_THRESHOLD = 1.2 - -TRUNK_BRANCH_NAMES = {"main", "master", "develop", "dev"} - -CONVENTIONAL_PREFIX_RE = re.compile(r"^([a-z][a-z0-9_-]*)(\([^)]+\))?(!)?\s*:\s*(.+)$") -logger = get_logger() - - -def _run(cmd: list[str], cwd: str) -> tuple[int, str, str]: - try: - r = subprocess.run( - cmd, - cwd=cwd, - capture_output=True, - text=True, - timeout=GIT_TIMEOUT, - ) - - return r.returncode, r.stdout, r.stderr - except subprocess.TimeoutExpired: - return 1, "", f"git command timed out after {GIT_TIMEOUT}s" - except Exception as exc: - return 1, "", str(exc) - - -def _parse_commit_subject(subject: str) -> tuple[str | None, str | None, bool]: - """Return (prefix, scope, is_breaking) or (None, None, False).""" - m = CONVENTIONAL_PREFIX_RE.match(subject.strip()) - - if not m: - return None, None, False - - prefix = m.group(1) - scope_raw = m.group(2) - breaking = m.group(3) == "!" - scope = scope_raw.strip("()") if scope_raw else None - - return prefix, scope, breaking - - -def _pct(data: list[int], p: int) -> int: - if not data: - return 0 - - s = sorted(data) - idx = max(0, int(len(s) * p / 100) - 1) - - return s[min(idx, len(s) - 1)] - - -def _analyze_subjects( - out: str, -) -> tuple[dict[str, int], dict[str, str], dict[str, int], int, int, list[int], int]: - """Parse subject log lines and return subject stats plus signed-commit count.""" - prefix_counts: dict[str, int] = {} - prefix_examples: dict[str, str] = {} - scope_counts: dict[str, int] = {} - scoped_count = 0 - total = 0 - subject_lengths: list[int] = [] - signed_count = 0 - - for line in out.strip().splitlines(): - parts = line.split("|", 3) - - if len(parts) < 4: - continue - - _hash, subject, _email, gpg = parts - total += 1 - subject_lengths.append(len(subject)) - - if gpg == "G": - signed_count += 1 - - prefix, scope, _breaking = _parse_commit_subject(subject) - bucket = prefix if prefix else "unprefixed" - - prefix_counts[bucket] = prefix_counts.get(bucket, 0) + 1 - prefix_examples.setdefault(bucket, subject) - - if scope: - scoped_count += 1 - scope_counts[scope] = scope_counts.get(scope, 0) + 1 - - return ( - prefix_counts, - prefix_examples, - scope_counts, - scoped_count, - total, - subject_lengths, - signed_count, - ) - - -def _analyze_bodies(cwd: str) -> int: - """Return count of commits that have a body.""" - cmd = ["git", "log", "--format=%H|%b", "--no-merges"] - rc, out, err = _run(cmd, cwd) - - if rc != 0: - logger.warning( - "Git command failed: cmd=%s cwd=%s returncode=%s stderr=%s", - cmd, - cwd, - rc, - err.strip(), - ) - - return 0 - - body_hashes: set[str] = set() - current_hash = None - has_body = False - - for line in out.splitlines(): - if "|" in line and len(line.split("|", 1)[0]) == GIT_HASH_LENGTH: - if current_hash and has_body: - body_hashes.add(current_hash) - - parts = line.split("|", 1) - current_hash = parts[0] - has_body = bool(parts[1].strip()) if len(parts) > 1 else False - elif line.strip() and current_hash: - has_body = True - - if current_hash and has_body: - body_hashes.add(current_hash) - - return len(body_hashes) - - -def _analyze_branches(cwd: str) -> tuple[dict[str, int], int, list[str]]: - """Return (branch_prefixes, active_count, examples).""" - cmd = ["git", "branch", "-a"] - rc, out, err = _run(cmd, cwd) - branch_prefixes: dict[str, int] = {} - active_count = 0 - examples: list[str] = [] - - if rc != 0: - logger.warning( - "Git command failed: cmd=%s cwd=%s returncode=%s stderr=%s", - cmd, - cwd, - rc, - err.strip(), - ) - - return branch_prefixes, active_count, examples - - for line in out.splitlines(): - name = line.strip().lstrip("* ").split("->")[0].strip() - name = re.sub(r"^remotes/[^/]+/", "", name) - - if name in TRUNK_BRANCH_NAMES or name == "HEAD": - continue - - active_count += 1 - - if "/" in name: - prefix = name.split("/")[0] + "/" - branch_prefixes[prefix] = branch_prefixes.get(prefix, 0) + 1 - - examples.append(name) - - return branch_prefixes, active_count, examples - - -def _detect_merge_strategy(cwd: str) -> tuple[str, str]: - """Return (strategy, evidence).""" - cmd = ["git", "log", "--merges", "--format=%P", f"-{MERGE_COMMITS_SAMPLE}"] - rc, out, err = _run(cmd, cwd) - - if rc != 0: - logger.warning( - "Git command failed: cmd=%s cwd=%s returncode=%s stderr=%s", - cmd, - cwd, - rc, - err.strip(), - ) - - return "unknown", "insufficient data" - - merge_lines = [line.strip() for line in out.splitlines() if line.strip()] - - if not merge_lines: - return "rebase", "no merge commits in history" - - parent_counts = [len(line.split()) for line in merge_lines] - avg_parents = sum(parent_counts) / len(parent_counts) - - if avg_parents <= SQUASH_PARENT_THRESHOLD: - return "squash", "merge commits have single parent" - - return "merge", "merge commits have multiple parents" - - -def analyze(repo_path: str) -> dict: - try: - repo = validate_repo(repo_path) - except ValueError as exc: - return {"error": str(exc), "script": "git"} - - if not (repo / ".git").exists(): - return {"error": "not a git repository", "script": "git"} - - cwd = str(repo) - - cmd = ["git", "log", "--format=%H|%s|%ae|%G?", "--no-merges"] - rc, out, err = _run(cmd, cwd) - - if rc != 0: - logger.warning( - "Git command failed: cmd=%s cwd=%s returncode=%s stderr=%s", - cmd, - cwd, - rc, - err.strip(), - ) - - return {"error": "git log failed", "script": "git"} - - ( - prefix_counts, - prefix_examples, - scope_counts, - scoped_count, - total, - subject_lengths, - signed_count, - ) = _analyze_subjects(out) - - if total == 0: - return {"error": "empty repository", "script": "git"} - - prefixes: dict[str, dict] = {} - - for k, count in sorted(prefix_counts.items(), key=lambda item: -item[1]): - prefixes[k] = { - "count": count, - "pct": round(count / total * 100, 1), - "example": prefix_examples[k], - } - - top_scopes = sorted(scope_counts, key=lambda k: -scope_counts[k])[ - :MAX_SCOPE_EXAMPLES - ] - - body_count = _analyze_bodies(cwd) - branch_prefixes, active_count, examples = _analyze_branches(cwd) - merge_strategy, merge_evidence = _detect_merge_strategy(cwd) - - return { - "commits": { - "total": total, - "prefixes": prefixes, - "scoped": { - "uses_scopes": scoped_count > 0, - "scope_examples": top_scopes, - "pct_scoped": round(scoped_count / total * 100, 1) if total else 0, - }, - "subject_length": { - "p50": _pct(subject_lengths, 50), - "p95": _pct(subject_lengths, 95), - "max": max(subject_lengths, default=0), - }, - "has_body": { - "pct_with_body": round(body_count / total * 100, 1) if total else 0, - }, - "gpg_signed": { - "pct_signed": round(signed_count / total * 100, 1) if total else 0, - }, - }, - "branches": { - "prefixes": dict(sorted(branch_prefixes.items(), key=lambda x: -x[1])), - "active_count": active_count, - "naming_example": examples[0] if examples else None, - }, - "merge_strategy": { - "detected": merge_strategy, - "evidence": merge_evidence, - }, - } - - -def main(argv: list[str] | None = None) -> int: - return run_command_main( - argv=argv, - description=__doc__, - command_fn=analyze, - script_name="git", - ) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/agentskill/commands/graph.py b/agentskill/commands/graph.py deleted file mode 100644 index 26402be..0000000 --- a/agentskill/commands/graph.py +++ /dev/null @@ -1,1361 +0,0 @@ -#!/usr/bin/env python3 -"""Build the internal import graph. Identify coupling, circular dependencies, monorepo boundaries. - -Traces only internal imports — external (stdlib, third-party) are ignored. - -Usage: - python scripts/graph.py - python scripts/graph.py --lang python - python scripts/graph.py --pretty -""" - -import ast -import os -import re -import sys -from pathlib import Path - -from agentskill.common.constants import should_skip_dir -from agentskill.common.fs import read_text, validate_repo -from agentskill.common.languages import language_for_path -from agentskill.lib.cli_entrypoint import run_command_main - -MAX_EDGES = 200 -MAX_CYCLES = 20 -MAX_MOST_DEPENDED = 10 -MIN_MONOREPO_SERVICES = 2 - -MONOREPO_BOUNDARY_DIRS = ["services", "packages", "apps", "modules"] - - -def _collect_files(repo: Path, lang: str) -> list[Path]: - found = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - fpath = Path(dirpath) / fn - spec = language_for_path(fpath) - - if spec and spec.id == lang: - found.append(fpath) - - return found - - -def _path_to_module(path: Path, repo: Path) -> str: - rel = path.relative_to(repo) - parts = list(rel.parts) - - if parts[-1] == "__init__.py": - parts = parts[:-1] - else: - parts[-1] = Path(parts[-1]).stem - - return ".".join(parts) - - -def _file_for_module(module: str, repo: Path) -> Path | None: - parts = module.split(".") - - candidates = [ - repo / Path(*parts).with_suffix(".py"), - repo / Path(*parts) / "__init__.py", - ] - - for c in candidates: - if c.exists(): - return c - - return None - - -def _resolve_absolute_import(target: str, module_set: set[str]) -> str | None: - """Return the internal module name for a bare `import X` statement, or None.""" - if target in module_set: - return target - - prefix_match = next((m for m in module_set if target.startswith(m + ".")), None) - - if prefix_match: - return prefix_match - - return None - - -def _resolve_relative_import( - node: ast.ImportFrom, mod: str, module_set: set[str] -) -> str | None: - """Return the internal module name for a `from . import X` statement, or None.""" - if node.module is None: - return None - - if node.level and node.level > 0: - parts = mod.split(".") - base_parts = parts[: max(0, len(parts) - node.level)] - - resolved = ( - ".".join(base_parts + [node.module]) - if node.module - else ".".join(base_parts) - ) - else: - resolved = node.module - - if resolved in module_set or any(resolved.startswith(m) for m in module_set): - return resolved - - return None - - -def _build_python_graph(files: list[Path], repo: Path) -> dict: - modules = {_path_to_module(f, repo): f for f in files} - module_set = set(modules.keys()) - edges: list[dict] = [] - parse_errors: list[str] = [] - adjacency: dict[str, list[str]] = {m: [] for m in module_set} - - for mod, fpath in modules.items(): - try: - source = read_text(fpath) - tree = ast.parse(source) - except Exception: - parse_errors.append(str(fpath.relative_to(repo))) - continue - - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - resolved = _resolve_absolute_import(alias.name, module_set) - - if resolved: - edges.append({"from": mod, "to": resolved, "line": node.lineno}) - adjacency[mod].append(resolved) - - elif isinstance(node, ast.ImportFrom): - resolved = _resolve_relative_import(node, mod, module_set) - - if resolved: - edges.append({"from": mod, "to": resolved, "line": node.lineno}) - - if resolved in adjacency: - adjacency[mod].append(resolved) - - return _graph_result(sorted(module_set), edges, adjacency, parse_errors) - - -def _strip_js_ts_comments(content: str) -> str: - content = re.sub(r"//.*", "", content) - content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL) - return content - - -def _resolve_js_ts_import( - importer: Path, specifier: str, repo: Path, file_stems: set[str] -) -> str | None: - if not specifier.startswith("./") and not specifier.startswith("../"): - return None - - base = importer.parent / specifier - - candidates = [ - base, - base.with_suffix(".ts"), - base.with_suffix(".tsx"), - base.with_suffix(".js"), - base.with_suffix(".jsx"), - base.with_suffix(".mjs"), - base.with_suffix(".cjs"), - base / "index.ts", - base / "index.tsx", - base / "index.js", - base / "index.jsx", - ] - - for c in candidates: - try: - rel = str(c.resolve(strict=False).relative_to(repo.resolve())) - except ValueError: - continue - - if rel in file_stems: - return rel - - return None - - -def _extract_js_ts_imports(content: str) -> list[tuple[str, int]]: - content = _strip_js_ts_comments(content) - results: list[tuple[str, int]] = [] - - es_import_re = re.compile( - r"(?:^|\s)import\s+(?:(?:\{[^}]+\}|[^'\"]+)\s+from\s+)?['\"]([^'\"]+)['\"]", - re.MULTILINE, - ) - - reexport_re = re.compile( - r"(?:^|\s)export\s+(?:\{[^}]+\}|\*\s+)?\s*from\s+['\"]([^'\"]+)['\"]", - re.MULTILINE, - ) - - require_re = re.compile( - r"(?:^|\s)require\s*\(\s*['\"]([^'\"]+)['\"]\s*\)", - re.MULTILINE, - ) - - for lineno, line in enumerate(content.splitlines(), 1): - for pattern in (es_import_re, reexport_re, require_re): - for m in pattern.finditer(line): - spec = m.group(1) - - if spec.startswith("./") or spec.startswith("../"): - results.append((spec, lineno)) - - return results - - -def _build_ts_graph(files: list[Path], repo: Path) -> dict: - edges: list[dict] = [] - adjacency: dict[str, list[str]] = {} - parse_errors: list[str] = [] - - file_stems = {str(f.relative_to(repo)): f for f in files} - stem_set = set(file_stems.keys()) - - for fpath in files: - rel = str(fpath.relative_to(repo)) - adjacency.setdefault(rel, []) - - try: - source = read_text(fpath) - except Exception: - parse_errors.append(rel) - continue - - for spec, lineno in _extract_js_ts_imports(source): - resolved = _resolve_js_ts_import(fpath, spec, repo, stem_set) - - if resolved and resolved != rel: - edges.append({"from": rel, "to": resolved, "line": lineno}) - adjacency[rel].append(resolved) - - return _graph_result(sorted(adjacency.keys()), edges, adjacency, parse_errors) - - -def _strip_go_comments(source: str) -> str: - source = re.sub(r"//.*", "", source) - source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) - return source - - -def _extract_go_imports(source: str) -> list[tuple[str, int]]: - source = _strip_go_comments(source) - results: list[tuple[str, int]] = [] - - import_block_re = re.compile(r"import\s*\(([^)]+)\)", re.DOTALL) - single_import_re = re.compile(r'^\s*import\s+"([^"]+)"') - quoted_re = re.compile(r'"([^"]+)"') - - for lineno, line in enumerate(source.splitlines(), 1): - single_match = single_import_re.match(line) - if single_match: - results.append((single_match.group(1), lineno)) - - for m in import_block_re.finditer(source): - block_start = source[: m.start()].count("\n") + 1 - for im in quoted_re.findall(m.group(1)): - results.append((im, block_start)) - - return results - - -def _detect_go_module(repo: Path) -> str: - gomod = repo / "go.mod" - if not gomod.exists(): - return "" - for line in read_text(gomod).splitlines(): - if line.startswith("module "): - return line.split()[1] - return "" - - -def _detect_go_packages(files: list[Path], repo: Path) -> dict[str, str]: - pkg_map: dict[str, str] = {} - - for fpath in files: - pkg_dir = str(fpath.parent.relative_to(repo)) - - if pkg_dir in pkg_map: - continue - - try: - source = read_text(fpath) - except Exception: - continue - - for line in source.splitlines(): - m = re.match(r"^package\s+(\w+)", line) - - if m: - pkg_map[pkg_dir] = m.group(1) - break - - return pkg_map - - -def _build_go_graph(files: list[Path], repo: Path) -> dict: - module_prefix = _detect_go_module(repo) - - edges: list[dict] = [] - adjacency: dict[str, list[str]] = {} - parse_errors: list[str] = [] - - for fpath in files: - rel = str(fpath.relative_to(repo)) - pkg = str(fpath.parent.relative_to(repo)) - adjacency.setdefault(pkg, []) - - try: - source = read_text(fpath) - except Exception: - parse_errors.append(rel) - continue - - for imp, lineno in _extract_go_imports(source): - if module_prefix and imp.startswith(module_prefix): - internal_path = imp[len(module_prefix) :].lstrip("/") - edges.append({"from": pkg, "to": internal_path, "line": lineno}) - adjacency[pkg].append(internal_path) - - return _graph_result(sorted(adjacency.keys()), edges, adjacency, parse_errors) - - -def _strip_rust_comments(source: str) -> str: - source = re.sub(r"//.*", "", source) - source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) - return source - - -def _extract_rust_mods_and_uses(source: str) -> list[tuple[str, int]]: - source = _strip_rust_comments(source) - results: list[tuple[str, int]] = [] - - mod_re = re.compile(r"^\s*(?:pub\s+)?mod\s+(\w+)", re.MULTILINE) - use_re = re.compile( - r"^\s*(?:pub\s+)?use\s+(crate::[\w:]+|super::[\w:]+|self::[\w:]+)", - re.MULTILINE, - ) - - for m in mod_re.finditer(source): - results.append(("mod:" + m.group(1), source[: m.start()].count("\n") + 1)) - - for m in use_re.finditer(source): - results.append(("use:" + m.group(1), source[: m.start()].count("\n") + 1)) - - return results - - -def _resolve_rust_mod( - mod_name: str, current_file: Path, repo: Path, all_files: set[str] -) -> str | None: - parent = current_file.parent - - candidates = [ - parent / f"{mod_name}.rs", - parent / mod_name / "mod.rs", - parent / "src" / f"{mod_name}.rs", - parent / "src" / mod_name / "mod.rs", - ] - - if current_file.name in ("mod.rs", "lib.rs", "main.rs"): - candidates = [ - parent / f"{mod_name}.rs", - parent / mod_name / "mod.rs", - ] - - for c in candidates: - try: - rel = str(c.relative_to(repo)) - except ValueError: - continue - - if rel in all_files: - return rel - - return None - - -def _resolve_rust_use_path( - use_path: str, current_file: Path, repo: Path, all_files: set[str] -) -> str | None: - path_part = use_path.split("::")[0] if "::" in use_path else use_path - - if path_part in ("crate", "super", "self"): - return None - - parent = current_file.parent - candidates = [ - parent / f"{path_part}.rs", - parent / path_part / "mod.rs", - ] - - for c in candidates: - try: - rel = str(c.relative_to(repo)) - except ValueError: - continue - - if rel in all_files: - return rel - - return None - - -def _build_rust_graph(files: list[Path], repo: Path) -> dict: - file_set = {str(f.relative_to(repo)): f for f in files} - file_rel_set = set(file_set.keys()) - - edges: list[dict] = [] - adjacency: dict[str, list[str]] = {} - parse_errors: list[str] = [] - - for fpath in files: - rel = str(fpath.relative_to(repo)) - adjacency.setdefault(rel, []) - - try: - source = read_text(fpath) - except Exception: - parse_errors.append(rel) - continue - - for kind_path, lineno in _extract_rust_mods_and_uses(source): - if kind_path.startswith("mod:"): - mod_name = kind_path[4:] - resolved = _resolve_rust_mod(mod_name, fpath, repo, file_rel_set) - - if resolved and resolved != rel: - edges.append({"from": rel, "to": resolved, "line": lineno}) - adjacency[rel].append(resolved) - - elif kind_path.startswith("use:"): - use_path = kind_path[4:] - resolved = _resolve_rust_use_path(use_path, fpath, repo, file_rel_set) - - if resolved and resolved != rel: - edges.append({"from": rel, "to": resolved, "line": lineno}) - adjacency[rel].append(resolved) - - return _graph_result(sorted(adjacency.keys()), edges, adjacency, parse_errors) - - -def _strip_jvm_comments(source: str) -> str: - source = re.sub(r"//.*", "", source) - source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) - return source - - -def _strip_c_family_comments(source: str) -> str: - source = re.sub(r"//.*", "", source) - source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) - return source - - -def _strip_ruby_comments(source: str) -> str: - return re.sub(r"#.*", "", source) - - -def _strip_shell_comments(source: str) -> str: - lines = source.splitlines() - stripped: list[str] = [] - - for i, line in enumerate(lines): - if i == 0 and line.startswith("#!"): - stripped.append(line) - continue - - stripped.append(re.sub(r"#.*", "", line)) - - return "\n".join(stripped) - - -def _strip_swift_comments(source: str) -> str: - source = re.sub(r"//.*", "", source) - source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) - return source - - -def _extract_jvm_package(content: str) -> str | None: - stripped = _strip_jvm_comments(content) - - match = re.search( - r"^[ \t]*package\s+([A-Za-z_][\w.]*)[ \t]*;?[ \t]*$", - stripped, - re.MULTILINE, - ) - - return match.group(1) if match else None - - -def _extract_jvm_imports(content: str) -> list[tuple[str, int]]: - stripped = _strip_jvm_comments(content) - imports: list[tuple[str, int]] = [] - - pattern = re.compile( - r"^[ \t]*import\s+(?:static\s+)?([A-Za-z_][\w.]*)(?:\.\*)?[ \t]*;?[ \t]*$", - re.MULTILINE, - ) - - for match in pattern.finditer(stripped): - imports.append((match.group(1), stripped[: match.start()].count("\n") + 1)) - - return imports - - -def _jvm_declared_name(path: Path) -> str | None: - if path.suffix.lower() == ".kts": - return None - - stem = path.stem - - if stem and stem[0].isupper(): - return stem - - return None - - -def _build_jvm_package_index( - files: list[Path], repo: Path -) -> tuple[dict[str, str], dict[str, set[str]]]: - symbol_index: dict[str, str] = {} - package_index: dict[str, set[str]] = {} - - for fpath in files: - rel = str(fpath.relative_to(repo)) - - try: - source = read_text(fpath) - except Exception: - continue - - package_name = _extract_jvm_package(source) - declared_name = _jvm_declared_name(fpath) - - if package_name: - package_index.setdefault(package_name, set()).add(rel) - - if declared_name: - symbol_index[f"{package_name}.{declared_name}"] = rel - elif declared_name: - symbol_index[declared_name] = rel - - return symbol_index, package_index - - -def _resolve_jvm_import( - import_name: str, - symbol_index: dict[str, str], - package_index: dict[str, set[str]], -) -> str | None: - if import_name in symbol_index: - return symbol_index[import_name] - - package_name = import_name.rsplit(".", 1)[0] if "." in import_name else import_name - matches = package_index.get(package_name) - - if not matches: - return None - - if len(matches) == 1: - return next(iter(matches)) - - return None - - -def _build_jvm_graph(files: list[Path], repo: Path) -> dict: - edges: list[dict] = [] - adjacency: dict[str, list[str]] = {} - parse_errors: list[str] = [] - symbol_index, package_index = _build_jvm_package_index(files, repo) - - for fpath in files: - rel = str(fpath.relative_to(repo)) - adjacency.setdefault(rel, []) - - try: - source = read_text(fpath) - except Exception: - parse_errors.append(rel) - continue - - for import_name, lineno in _extract_jvm_imports(source): - resolved = _resolve_jvm_import(import_name, symbol_index, package_index) - - if resolved and resolved != rel: - edges.append({"from": rel, "to": resolved, "line": lineno}) - adjacency[rel].append(resolved) - - return _graph_result(sorted(adjacency.keys()), edges, adjacency, parse_errors) - - -def _extract_csharp_namespace(content: str) -> str | None: - stripped = _strip_c_family_comments(content) - - match = re.search( - r"^\s*namespace\s+([A-Za-z_][\w.]*)\s*(?:;|\{)", - stripped, - re.MULTILINE, - ) - - return match.group(1) if match else None - - -def _extract_csharp_usings(content: str) -> list[tuple[str, int]]: - stripped = _strip_c_family_comments(content) - results: list[tuple[str, int]] = [] - - pattern = re.compile( - r"^\s*using\s+(?:static\s+)?([A-Za-z_][\w.]*)\s*;", - re.MULTILINE, - ) - - for match in pattern.finditer(stripped): - results.append((match.group(1), stripped[: match.start()].count("\n") + 1)) - - return results - - -def _build_csharp_index( - files: list[Path], repo: Path -) -> tuple[dict[str, str], dict[str, set[str]]]: - symbol_index: dict[str, str] = {} - namespace_index: dict[str, set[str]] = {} - - for fpath in files: - rel = str(fpath.relative_to(repo)) - - try: - source = read_text(fpath) - except Exception: - continue - - namespace_name = _extract_csharp_namespace(source) - - if namespace_name: - namespace_index.setdefault(namespace_name, set()).add(rel) - symbol_index[f"{namespace_name}.{fpath.stem}"] = rel - else: - symbol_index[fpath.stem] = rel - - return symbol_index, namespace_index - - -def _resolve_csharp_using( - using_name: str, - symbol_index: dict[str, str], - namespace_index: dict[str, set[str]], -) -> str | None: - if using_name in symbol_index: - return symbol_index[using_name] - - matches = namespace_index.get(using_name) - - if matches and len(matches) == 1: - return next(iter(matches)) - - for namespace_name, files in namespace_index.items(): - if using_name.startswith(namespace_name + ".") and len(files) == 1: - return next(iter(files)) - - return None - - -def _build_csharp_graph(files: list[Path], repo: Path) -> dict: - edges: list[dict] = [] - adjacency: dict[str, list[str]] = {} - parse_errors: list[str] = [] - symbol_index, namespace_index = _build_csharp_index(files, repo) - - for fpath in files: - rel = str(fpath.relative_to(repo)) - adjacency.setdefault(rel, []) - - try: - source = read_text(fpath) - except Exception: - parse_errors.append(rel) - continue - - for using_name, lineno in _extract_csharp_usings(source): - resolved = _resolve_csharp_using(using_name, symbol_index, namespace_index) - - if resolved and resolved != rel: - edges.append({"from": rel, "to": resolved, "line": lineno}) - adjacency[rel].append(resolved) - - return _graph_result(sorted(adjacency.keys()), edges, adjacency, parse_errors) - - -def _extract_c_cpp_includes(content: str) -> list[tuple[str, str, int]]: - stripped = _strip_c_family_comments(content) - results: list[tuple[str, str, int]] = [] - pattern = re.compile(r'^\s*#include\s*([<"])([^>"]+)[>"]', re.MULTILINE) - - for match in pattern.finditer(stripped): - delim = match.group(1) - include_name = match.group(2).strip() - line = stripped[: match.start()].count("\n") + 1 - results.append((include_name, delim, line)) - - return results - - -def _build_include_lookup(repo: Path) -> dict[str, str]: - lookup: dict[str, str] = {} - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - fpath = Path(dirpath) / fn - rel = str(fpath.relative_to(repo)) - posix_rel = Path(rel).as_posix() - key = posix_rel.lower() - lookup.setdefault(key, rel) - lookup.setdefault(fpath.name.lower(), rel) - - parts = fpath.parts - for root_name in ("include", "src", "lib"): - if root_name in parts: - idx = parts.index(root_name) - subpath = Path(*parts[idx + 1 :]).as_posix().lower() - - if subpath: - lookup.setdefault(subpath, rel) - - return lookup - - -def _resolve_c_cpp_include( - importer: Path, - include_name: str, - repo: Path, - include_lookup: dict[str, str], -) -> str | None: - normalized = Path(include_name).as_posix().lower() - - path_candidates = [ - importer.parent / include_name, - repo / include_name, - repo / "include" / include_name, - repo / "src" / include_name, - repo / "lib" / include_name, - ] - - for candidate_path in path_candidates: - if candidate_path.exists(): - try: - return str(candidate_path.resolve().relative_to(repo.resolve())) - except ValueError: - continue - - candidates: list[str] = [normalized] - - candidates.extend( - [f"include/{normalized}", f"src/{normalized}", f"lib/{normalized}"] - ) - - for candidate in candidates: - if candidate in include_lookup: - return include_lookup[candidate] - - return None - - -def _build_c_cpp_graph(files: list[Path], repo: Path) -> dict: - edges: list[dict] = [] - adjacency: dict[str, list[str]] = {} - parse_errors: list[str] = [] - include_lookup = _build_include_lookup(repo) - - for fpath in files: - rel = str(fpath.relative_to(repo)) - adjacency.setdefault(rel, []) - - try: - source = read_text(fpath) - except Exception: - parse_errors.append(rel) - continue - - for include_name, _delim, lineno in _extract_c_cpp_includes(source): - resolved = _resolve_c_cpp_include(fpath, include_name, repo, include_lookup) - - if resolved and resolved != rel: - edges.append({"from": rel, "to": resolved, "line": lineno}) - adjacency[rel].append(resolved) - - return _graph_result(sorted(adjacency.keys()), edges, adjacency, parse_errors) - - -def _extract_ruby_requires(content: str) -> list[tuple[str, str, int]]: - stripped = _strip_ruby_comments(content) - results: list[tuple[str, str, int]] = [] - pattern = re.compile( - r'^\s*(require_relative|require)\s+["\']([^"\']+)["\']', - re.MULTILINE, - ) - - for match in pattern.finditer(stripped): - results.append( - ( - match.group(1), - match.group(2), - stripped[: match.start()].count("\n") + 1, - ) - ) - - return results - - -def _resolve_ruby_require( - importer: Path, kind: str, target: str, repo: Path, file_set: set[str] -) -> str | None: - candidates: list[Path] = [] - - if kind == "require_relative": - base = importer.parent / target - candidates.extend([base, base.with_suffix(".rb"), base / "index.rb"]) - else: - for prefix in (repo / "lib", repo / "app", repo): - base = prefix / target - candidates.extend([base, base.with_suffix(".rb"), base / "index.rb"]) - - for candidate in candidates: - try: - rel = str(candidate.resolve().relative_to(repo.resolve())) - except ValueError: - continue - - if rel in file_set: - return rel - - return None - - -def _build_ruby_graph(files: list[Path], repo: Path) -> dict: - edges: list[dict] = [] - adjacency: dict[str, list[str]] = {} - parse_errors: list[str] = [] - file_set = {str(f.relative_to(repo)) for f in files} - - for fpath in files: - rel = str(fpath.relative_to(repo)) - adjacency.setdefault(rel, []) - - try: - source = read_text(fpath) - except Exception: - parse_errors.append(rel) - continue - - for kind, target, lineno in _extract_ruby_requires(source): - resolved = _resolve_ruby_require(fpath, kind, target, repo, file_set) - - if resolved and resolved != rel: - edges.append({"from": rel, "to": resolved, "line": lineno}) - adjacency[rel].append(resolved) - - return _graph_result(sorted(adjacency.keys()), edges, adjacency, parse_errors) - - -def _extract_php_namespace(content: str) -> str | None: - stripped = _strip_c_family_comments(content) - match = re.search(r"^\s*namespace\s+([A-Za-z_][\w\\]*)\s*;", stripped, re.MULTILINE) - return match.group(1) if match else None - - -def _extract_php_uses(content: str) -> list[tuple[str, int]]: - stripped = _strip_c_family_comments(content) - results: list[tuple[str, int]] = [] - pattern = re.compile(r"^[ \t]*use\s+([A-Za-z_][\w\\]*)[ \t]*;", re.MULTILINE) - - for match in pattern.finditer(stripped): - results.append((match.group(1), stripped[: match.start()].count("\n") + 1)) - - return results - - -def _build_php_index( - files: list[Path], repo: Path -) -> tuple[dict[str, str], dict[str, set[str]]]: - symbol_index: dict[str, str] = {} - namespace_index: dict[str, set[str]] = {} - - for fpath in files: - rel = str(fpath.relative_to(repo)) - - try: - source = read_text(fpath) - except Exception: - continue - - namespace_name = _extract_php_namespace(source) - - if namespace_name: - namespace_index.setdefault(namespace_name, set()).add(rel) - symbol_index[f"{namespace_name}\\{fpath.stem}"] = rel - else: - symbol_index[fpath.stem] = rel - - return symbol_index, namespace_index - - -def _resolve_php_use( - use_name: str, symbol_index: dict[str, str], namespace_index: dict[str, set[str]] -) -> str | None: - if use_name in symbol_index: - return symbol_index[use_name] - - namespace_name = use_name.rsplit("\\", 1)[0] if "\\" in use_name else use_name - matches = namespace_index.get(namespace_name) - - if matches and len(matches) == 1: - return next(iter(matches)) - - return None - - -def _build_php_graph(files: list[Path], repo: Path) -> dict: - edges: list[dict] = [] - adjacency: dict[str, list[str]] = {} - parse_errors: list[str] = [] - symbol_index, namespace_index = _build_php_index(files, repo) - - for fpath in files: - rel = str(fpath.relative_to(repo)) - adjacency.setdefault(rel, []) - - try: - source = read_text(fpath) - except Exception: - parse_errors.append(rel) - continue - - for use_name, lineno in _extract_php_uses(source): - resolved = _resolve_php_use(use_name, symbol_index, namespace_index) - - if resolved and resolved != rel: - edges.append({"from": rel, "to": resolved, "line": lineno}) - adjacency[rel].append(resolved) - - return _graph_result(sorted(adjacency.keys()), edges, adjacency, parse_errors) - - -def _extract_shell_sources(content: str) -> list[tuple[str, int]]: - stripped = _strip_shell_comments(content) - results: list[tuple[str, int]] = [] - pattern = re.compile( - r'^[ \t]*(?:source|\.)\s+([^\s"\']+|["\'][^"\']+["\'])', - re.MULTILINE, - ) - - for match in pattern.finditer(stripped): - target = match.group(1).strip("\"'") - - if "$" in target or "{" in target: - continue - - results.append((target, stripped[: match.start()].count("\n") + 1)) - - return results - - -def _resolve_shell_source( - importer: Path, target: str, repo: Path, file_set: set[str] -) -> str | None: - base = importer.parent / target - candidates = [base, repo / target] - - for candidate in candidates: - try: - rel = str(candidate.relative_to(repo)) - except ValueError: - continue - - if rel in file_set: - return rel - - return None - - -def _build_shell_graph(files: list[Path], repo: Path) -> dict: - edges: list[dict] = [] - adjacency: dict[str, list[str]] = {} - parse_errors: list[str] = [] - file_set = {str(f.relative_to(repo)) for f in files} - - for fpath in files: - rel = str(fpath.relative_to(repo)) - adjacency.setdefault(rel, []) - - try: - source = read_text(fpath) - except Exception: - parse_errors.append(rel) - continue - - for target, lineno in _extract_shell_sources(source): - resolved = _resolve_shell_source(fpath, target, repo, file_set) - - if resolved and resolved != rel: - edges.append({"from": rel, "to": resolved, "line": lineno}) - adjacency[rel].append(resolved) - - return _graph_result(sorted(adjacency.keys()), edges, adjacency, parse_errors) - - -def _extract_swift_imports(content: str) -> list[tuple[str, int]]: - stripped = _strip_swift_comments(content) - results: list[tuple[str, int]] = [] - - pattern = re.compile( - r"^[ \t]*(?:@testable\s+)?import\s+([A-Za-z_]\w*)", - re.MULTILINE, - ) - - for match in pattern.finditer(stripped): - results.append((match.group(1), stripped[: match.start()].count("\n") + 1)) - - return results - - -def _swift_module_name(path: Path, repo: Path) -> str | None: - rel = path.relative_to(repo) - parts = rel.parts - - if len(parts) >= 2 and parts[0] == "Sources": - return parts[1] - - if len(parts) >= 2 and parts[0] == "Tests": - return parts[1].removesuffix("Tests") - - return None - - -def _build_swift_module_index(files: list[Path], repo: Path) -> dict[str, str]: - index: dict[str, str] = {} - - for fpath in files: - module_name = _swift_module_name(fpath, repo) - - if not module_name: - continue - - index.setdefault(module_name, str(fpath.relative_to(repo))) - - return index - - -def _build_swift_graph(files: list[Path], repo: Path) -> dict: - edges: list[dict] = [] - adjacency: dict[str, list[str]] = {} - parse_errors: list[str] = [] - module_index = _build_swift_module_index(files, repo) - - for fpath in files: - rel = str(fpath.relative_to(repo)) - adjacency.setdefault(rel, []) - - try: - source = read_text(fpath) - except Exception: - parse_errors.append(rel) - continue - - for module_name, lineno in _extract_swift_imports(source): - resolved = module_index.get(module_name) - - if resolved and resolved != rel: - edges.append({"from": rel, "to": resolved, "line": lineno}) - adjacency[rel].append(resolved) - - return _graph_result(sorted(adjacency.keys()), edges, adjacency, parse_errors) - - -def _is_objectivec_header(path: Path) -> bool: - if path.suffix.lower() != ".h": - return False - - content = read_text(path) - - return any( - marker in content - for marker in ("@interface", "@protocol", "@implementation", "#import") - ) - - -def _collect_objectivec_files(repo: Path) -> list[Path]: - found: list[Path] = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - fpath = Path(dirpath) / fn - suffix = fpath.suffix.lower() - - if suffix in {".m", ".mm"} or ( - suffix == ".h" and _is_objectivec_header(fpath) - ): - found.append(fpath) - - return found - - -def _extract_objc_imports(content: str) -> list[tuple[str, int]]: - stripped = _strip_c_family_comments(content) - results: list[tuple[str, int]] = [] - pattern = re.compile(r'^[ \t]*#(?:import|include)\s*[<"]([^>"]+)[>"]', re.MULTILINE) - - for match in pattern.finditer(stripped): - results.append( - (match.group(1).strip(), stripped[: match.start()].count("\n") + 1) - ) - - return results - - -def _resolve_objc_import( - importer: Path, import_name: str, repo: Path, include_lookup: dict[str, str] -) -> str | None: - normalized = Path(import_name).as_posix().lower() - - path_candidates = [ - importer.parent / import_name, - repo / import_name, - repo / "include" / import_name, - repo / "Headers" / import_name, - repo / "Sources" / import_name, - ] - - for candidate_path in path_candidates: - if candidate_path.exists(): - try: - return str(candidate_path.resolve().relative_to(repo.resolve())) - except ValueError: - continue - - candidates = [ - normalized, - f"include/{normalized}", - f"headers/{normalized}", - f"sources/{normalized}", - ] - - for candidate in candidates: - if candidate in include_lookup: - return include_lookup[candidate] - - return None - - -def _build_objectivec_graph(files: list[Path], repo: Path) -> dict: - edges: list[dict] = [] - adjacency: dict[str, list[str]] = {} - parse_errors: list[str] = [] - include_lookup = _build_include_lookup(repo) - - for fpath in files: - rel = str(fpath.relative_to(repo)) - adjacency.setdefault(rel, []) - - try: - source = read_text(fpath) - except Exception: - parse_errors.append(rel) - continue - - for import_name, lineno in _extract_objc_imports(source): - resolved = _resolve_objc_import(fpath, import_name, repo, include_lookup) - - if resolved and resolved != rel: - edges.append({"from": rel, "to": resolved, "line": lineno}) - adjacency[rel].append(resolved) - - return _graph_result(sorted(adjacency.keys()), edges, adjacency, parse_errors) - - -def _compute_most_depended( - adjacency: dict[str, list[str]], -) -> list[dict[str, str | int]]: - dep_counts: dict[str, int] = {} - - for deps in adjacency.values(): - for d in deps: - dep_counts[d] = dep_counts.get(d, 0) + 1 - - most_depended = sorted(dep_counts.items(), key=lambda item: -item[1])[ - :MAX_MOST_DEPENDED - ] - - return [ - {"module": module, "dependents": dependents} - for module, dependents in most_depended - ] - - -def _find_cycles(adjacency: dict[str, list[str]]) -> list[list[str]]: - """DFS cycle detection. Returns list of cycles as ordered node lists.""" - visited: set[str] = set() - rec_stack: set[str] = set() - cycles: list[list[str]] = [] - path: list[str] = [] - - def dfs(node: str) -> None: - visited.add(node) - rec_stack.add(node) - path.append(node) - - for neighbor in adjacency.get(node, []): - if neighbor not in visited: - dfs(neighbor) - elif neighbor in rec_stack: - cycle_start = path.index(neighbor) - cycles.append(path[cycle_start:] + [neighbor]) - - path.pop() - rec_stack.discard(node) - - for node in list(adjacency.keys()): - if node not in visited: - dfs(node) - - return cycles - - -def _graph_result( - modules: list[str], - edges: list[dict], - adjacency: dict[str, list[str]], - parse_errors: list[str], -) -> dict: - return { - "modules": sorted(modules), - "edges": edges[:MAX_EDGES], - "boundary_violations": [], - "circular_dependencies": _find_cycles(adjacency)[:MAX_CYCLES], - "most_depended_on": _compute_most_depended(adjacency), - "parse_errors": parse_errors, - } - - -def _detect_monorepo_boundaries(repo: Path) -> dict: - for bd in MONOREPO_BOUNDARY_DIRS: - bd_path = repo / bd - - if not bd_path.is_dir(): - continue - - services = [ - d.name - for d in bd_path.iterdir() - if d.is_dir() and not d.name.startswith(".") - ] - - if len(services) >= MIN_MONOREPO_SERVICES: - return { - "detected": True, - "boundary_dir": bd, - "services": services, - "cross_service_imports": [], - } - - return {"detected": False, "services": [], "cross_service_imports": []} - - -def build_graph(repo_path: str, lang_filter: str | None = None) -> dict: - try: - repo = validate_repo(repo_path) - except ValueError as exc: - return {"error": str(exc), "script": "graph"} - - result: dict = {} - - langs = ( - [lang_filter] - if lang_filter - else [ - "python", - "typescript", - "javascript", - "go", - "rust", - "java", - "kotlin", - "csharp", - "c", - "cpp", - "ruby", - "php", - "bash", - "swift", - "objectivec", - ] - ) - - for lang in langs: - files = ( - _collect_objectivec_files(repo) - if lang == "objectivec" - else _collect_files(repo, lang) - ) - - if not files: - continue - - try: - if lang == "python": - result[lang] = _build_python_graph(files, repo) - elif lang in ("typescript", "javascript"): - result[lang] = _build_ts_graph(files, repo) - elif lang == "go": - result[lang] = _build_go_graph(files, repo) - elif lang == "rust": - result[lang] = _build_rust_graph(files, repo) - elif lang in ("java", "kotlin"): - result[lang] = _build_jvm_graph(files, repo) - elif lang == "csharp": - result[lang] = _build_csharp_graph(files, repo) - elif lang in ("c", "cpp"): - result[lang] = _build_c_cpp_graph(files, repo) - elif lang == "ruby": - result[lang] = _build_ruby_graph(files, repo) - elif lang == "php": - result[lang] = _build_php_graph(files, repo) - elif lang == "bash": - result[lang] = _build_shell_graph(files, repo) - elif lang == "swift": - result[lang] = _build_swift_graph(files, repo) - elif lang == "objectivec": - result[lang] = _build_objectivec_graph(files, repo) - except Exception as exc: - result[lang] = {"error": str(exc)} - - result["monorepo_boundaries"] = _detect_monorepo_boundaries(repo) - return result - - -def main(argv: list[str] | None = None) -> int: - return run_command_main( - argv=argv, - description=__doc__, - command_fn=build_graph, - script_name="graph", - supports_lang=True, - ) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/agentskill/commands/measure.py b/agentskill/commands/measure.py deleted file mode 100644 index 4487791..0000000 --- a/agentskill/commands/measure.py +++ /dev/null @@ -1,463 +0,0 @@ -#!/usr/bin/env python3 -"""Exact formatting metrics. No estimation. Count every line. - -Measures indentation, line lengths, blank line patterns, trailing newlines, -and trailing whitespace across all source files in the repository. - -Usage: - python scripts/measure.py - python scripts/measure.py --lang python - python scripts/measure.py --pretty -""" - -import ast -import os -import re -import sys -from collections import Counter -from pathlib import Path - -from agentskill.common.constants import should_skip_dir -from agentskill.common.fs import read_text, validate_repo -from agentskill.common.languages import language_for_extension -from agentskill.lib.cli_entrypoint import run_command_main - -MAX_SMALL_INDENT = 8 -MIN_FILES_FOR_LINE_LENGTH = 5 -MAX_FILES_REPORTED = 10 - -TOP_LEVEL_DEF_RE: dict[str, re.Pattern] = { - "typescript": re.compile( - r"^(export\s+)?(default\s+)?(async\s+)?function\s+\w+|^(export\s+)?(abstract\s+)?class\s+\w+|^(export\s+)?const\s+\w+\s*=\s*(async\s+)?\(" - ), - "javascript": re.compile( - r"^(export\s+)?(default\s+)?(async\s+)?function\s+\w+|^(export\s+)?(abstract\s+)?class\s+\w+|^(export\s+)?const\s+\w+\s*=\s*(async\s+)?\(" - ), - "go": re.compile(r"^func\s+"), - "rust": re.compile( - r"^(pub\s+)?(async\s+)?fn\s+\w+|^(pub\s+)?struct\s+\w+|^(pub\s+)?enum\s+\w+|^(pub\s+)?trait\s+\w+|^impl\s+" - ), - "ruby": re.compile(r"^def\s+\w+|^class\s+\w+|^module\s+\w+"), - "java": re.compile(r"^\s*(public|private|protected|static|final|abstract)\s+.*\{$"), - "kotlin": re.compile( - r"^\s*(?:public|private|protected|internal)?\s*(?:data\s+|sealed\s+|enum\s+)?(?:class|interface|object)\s+\w+|^\s*(?:public|private|protected|internal)?\s*(?:suspend\s+)?fun\s+\w+" - ), - "csharp": re.compile( - r"^\s*(?:public|private|protected|internal)?\s*(?:abstract\s+|static\s+|sealed\s+|partial\s+)?(?:class|interface|struct|enum|record)\s+\w+" - ), - "c": re.compile( - r"^\s*(?:typedef\s+)?(?:struct|enum)\s+\w+|^\s*[A-Za-z_][\w\s\*]+\s+\w+\s*\([^;]*\)\s*\{" - ), - "cpp": re.compile( - r"^\s*(?:namespace\s+\w+|template\s*<|class\s+\w+|struct\s+\w+|enum(?:\s+class)?\s+\w+)|^\s*[A-Za-z_][\w:\s<>\*&]+\s+\w+\s*\([^;]*\)\s*\{" - ), - "php": re.compile( - r"^\s*(?:class|interface|trait|enum)\s+\w+|^\s*function\s+\w+|^\s*(?:public|protected|private)\s+function\s+\w+" - ), - "bash": re.compile(r"^\s*(?:function\s+)?[A-Za-z_]\w*\s*\(\)\s*\{"), - "swift": re.compile( - r"^\s*(?:public|open|internal|private|fileprivate)?\s*(?:final\s+)?(?:struct|class|enum|protocol)\s+\w+|^\s*(?:public|open|internal|private|fileprivate)?\s*func\s+\w+|^\s*extension\s+\w+" - ), - "objectivec": re.compile( - r"^\s*@(?:interface|implementation|protocol)\s+\w+|^\s*[-+]\s*\([^)]+\)\s*\w+" - ), -} - -METHOD_DEF_RE: dict[str, re.Pattern] = { - "typescript": re.compile( - r"^ (public|private|protected|static|async|abstract|\w+)\s+\w+\s*\(" - ), - "javascript": re.compile(r"^ (async\s+)?\w+\s*\("), - "go": re.compile(r"^\tfunc\s+"), - "ruby": re.compile(r"^ def\s+"), - "java": re.compile( - r"^\s*(?:public|private|protected)\s+(?:static\s+)?[\w<>\[\], ?]+\s+\w+\s*\(" - ), - "kotlin": re.compile( - r"^\s*(?:public|private|protected|internal)?\s*(?:override\s+)?(?:suspend\s+)?fun\s+\w+\s*\(" - ), - "csharp": re.compile( - r"^\s*(?:public|private|protected|internal)\s+(?:static\s+|virtual\s+|override\s+|async\s+)?[\w<>\[\], ?]+\s+\w+\s*\(" - ), - "cpp": re.compile(r"^\s*[A-Za-z_][\w:\s<>\*&]+\s+\w+\s*\([^;]*\)\s*\{"), - "php": re.compile(r"^\s*(?:public|protected|private)\s+function\s+\w+\s*\("), - "bash": re.compile(r"^\s*(?:function\s+)?[A-Za-z_]\w*\s*\(\)\s*\{"), - "swift": re.compile( - r"^\s*(?:public|open|internal|private|fileprivate)?\s*func\s+\w+\s*\(" - ), - "objectivec": re.compile(r"^\s*[-+]\s*\([^)]+\)\s*\w+"), -} - - -def _collect_files(repo: Path, lang_filter: str | None) -> dict[str, list[Path]]: - by_lang: dict[str, list[Path]] = {} - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - ext = Path(fn).suffix.lower() - spec = language_for_extension(ext) - lang = spec.id if spec else None - - if not lang: - continue - - if lang_filter and lang != lang_filter: - continue - - by_lang.setdefault(lang, []).append(Path(dirpath) / fn) - - return by_lang - - -def _measure_indentation(lines: list[str]) -> dict: - space_sizes: list[int] = [] - has_spaces = False - has_tabs = False - - for line in lines: - if not line.rstrip(): - continue - - if line.startswith("\t"): - has_tabs = True - elif line.startswith(" "): - has_spaces = True - indent = len(line) - len(line.lstrip(" ")) - - if indent > 0: - space_sizes.append(indent) - - if has_tabs and not has_spaces: - return {"unit": "tabs", "size": 1} - - if not has_spaces: - return {"unit": "unknown", "size": 0} - - if not space_sizes: - return {"unit": "spaces", "size": 4} - - small = [s for s in space_sizes if s <= MAX_SMALL_INDENT] - if not small: - return {"unit": "spaces", "size": 4} - - cnt = Counter(small) - candidates = sorted(cnt.keys()) - unit = candidates[0] - - for s in [2, 4]: - if cnt[s] > cnt.get(unit, 0) * 0.5: - unit = s - break - - return {"unit": "spaces", "size": unit} - - -def _consensus_indentation( - votes: list[dict], - tab_files: list[str], - mixed_files: list[str], -) -> dict: - """Fold per-file indentation votes into a single consensus dict.""" - units = Counter(v["unit"] for v in votes if v["unit"] != "unknown") - sizes = Counter( - v["size"] for v in votes if v["unit"] != "unknown" and v["size"] > 0 - ) - return { - "unit": units.most_common(1)[0][0] if units else "spaces", - "size": sizes.most_common(1)[0][0] if sizes else 4, - "tab_files": tab_files[:MAX_FILES_REPORTED], - "mixed_files": mixed_files[:MAX_FILES_REPORTED], - } - - -def _percentile(sorted_data: list[int], p: int) -> int: - if not sorted_data: - return 0 - - idx = max(0, int(len(sorted_data) * p / 100) - 1) - - return sorted_data[min(idx, len(sorted_data) - 1)] - - -def _measure_line_lengths(all_lengths: list[int]) -> dict: - if len(all_lengths) < MIN_FILES_FOR_LINE_LENGTH: - return {} - - s = sorted(all_lengths) - - return { - "p50": _percentile(s, 50), - "p75": _percentile(s, 75), - "p95": _percentile(s, 95), - "p99": _percentile(s, 99), - "max": s[-1], - } - - -def _count_blanks_before_line(lines: list[str], i: int) -> int: - count = 0 - j = i - 1 - - while j >= 0 and not lines[j].strip(): - count += 1 - j -= 1 - - return count - - -def _dist_summary(data: list[int]) -> dict: - if not data: - return {} - - cnt = Counter(data) - mode = cnt.most_common(1)[0][0] - distribution = {str(k): v for k, v in sorted(cnt.items())} - - return {"mode": mode, "distribution": distribution} - - -def _blanks_between_top_level(tree: ast.AST, lines: list[str]) -> list[int]: - """Count blank lines before each top-level def/class.""" - top_nodes = [ - n - for n in ast.walk(tree) - if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) - and n.col_offset == 0 - ] - - top_nodes.sort(key=lambda n: n.lineno) - - return [ - _count_blanks_before_line(lines, n.lineno - 1) - for n in top_nodes - if n.lineno > 1 - ] - - -def _blanks_between_methods( - tree: ast.AST, lines: list[str] -) -> tuple[list[int], list[int]]: - """Count blank lines between class methods and after class declaration. - - Returns (between_methods, after_class_decl). - """ - between: list[int] = [] - after_decl: list[int] = [] - - for node in ast.walk(tree): - if not isinstance(node, ast.ClassDef): - continue - - methods = [ - n - for n in node.body - if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) - ] - - if not methods: - continue - - after_decl.append(_count_blanks_before_line(lines, methods[0].lineno - 1)) - - for m in methods[1:]: - between.append(_count_blanks_before_line(lines, m.lineno - 1)) - - return between, after_decl - - -def _blanks_after_imports(tree: ast.AST, lines: list[str]) -> list[int]: - """Count blank lines after the last import statement.""" - import_lines = [ - n.lineno for n in ast.walk(tree) if isinstance(n, (ast.Import, ast.ImportFrom)) - ] - - if not import_lines: - return [] - - last_import = max(import_lines) - - if last_import >= len(lines): - return [] - - count = 0 - j = last_import # 1-indexed → 0-indexed - - while j < len(lines) and not lines[j].strip(): - count += 1 - j += 1 - - return [count] - - -def _measure_blank_lines_python(files: list[Path]) -> dict: - """Use ast module for Python blank line analysis.""" - between_top_level: list[int] = [] - between_methods: list[int] = [] - after_class_decl: list[int] = [] - after_imports: list[int] = [] - - for fp in files: - try: - source = read_text(fp) - lines = source.splitlines() - tree = ast.parse(source) - except Exception: - continue - - between_top_level.extend(_blanks_between_top_level(tree, lines)) - - methods, decls = _blanks_between_methods(tree, lines) - between_methods.extend(methods) - after_class_decl.extend(decls) - - after_imports.extend(_blanks_after_imports(tree, lines)) - - return { - "between_top_level_defs": _dist_summary(between_top_level), - "between_methods": _dist_summary(between_methods), - "after_class_declaration": _dist_summary(after_class_decl), - "after_imports": _dist_summary(after_imports), - } - - -def _measure_blank_lines_generic(files: list[Path], lang: str) -> dict: - """Regex-based blank line analysis for non-Python languages.""" - pattern = TOP_LEVEL_DEF_RE.get(lang) - method_pattern = METHOD_DEF_RE.get(lang) - - between_top_level: list[int] = [] - between_methods: list[int] = [] - - for fp in files: - try: - lines = read_text(fp).splitlines() - except Exception: - continue - - if pattern: - for i, line in enumerate(lines): - if pattern.match(line) and i > 0: - between_top_level.append(_count_blanks_before_line(lines, i)) - - if method_pattern: - for i, line in enumerate(lines): - if method_pattern.match(line) and i > 0: - between_methods.append(_count_blanks_before_line(lines, i)) - - result: dict = {} - - if between_top_level: - result["between_top_level_defs"] = _dist_summary(between_top_level) - - if between_methods: - result["between_methods"] = _dist_summary(between_methods) - - return result - - -def _file_metrics(fp: Path) -> dict: - """Return raw per-file measurements for a single source file.""" - content = read_text(fp) - raw_lines = content.split("\n") - lines = raw_lines[:-1] if content.endswith("\n") else raw_lines - - indent = _measure_indentation(lines) - has_tabs = any(line.startswith("\t") for line in lines if line.strip()) - has_spaces = any(line.startswith(" ") for line in lines if line.strip()) - line_lengths = [len(line.rstrip("\n\r")) for line in lines if line.strip()] - trailing_newline = content.endswith("\n") - has_trailing_ws = any(re.search(r"\s+$", line) for line in lines if line.strip()) - - return { - "indent": indent, - "has_tabs": has_tabs, - "has_spaces": has_spaces, - "line_lengths": line_lengths, - "trailing_newline": trailing_newline, - "has_trailing_ws": has_trailing_ws, - "path": str(fp), - } - - -def _measure_lang(lang: str, files: list[Path]) -> dict: - all_line_lengths: list[int] = [] - indent_votes: list[dict] = [] - tab_files: list[str] = [] - mixed_files: list[str] = [] - trailing_newline_present = 0 - trailing_newline_absent = 0 - files_with_trailing_ws = 0 - - for fp in files: - m = _file_metrics(fp) - - indent_votes.append(m["indent"]) - all_line_lengths.extend(m["line_lengths"]) - - if m["trailing_newline"]: - trailing_newline_present += 1 - else: - trailing_newline_absent += 1 - - if m["has_trailing_ws"]: - files_with_trailing_ws += 1 - - if m["has_tabs"] and m["has_spaces"]: - mixed_files.append(m["path"]) - elif m["has_tabs"]: - tab_files.append(m["path"]) - - if lang == "python": - blank_lines = _measure_blank_lines_python(files) - else: - blank_lines = _measure_blank_lines_generic(files, lang) - - return { - "indentation": _consensus_indentation(indent_votes, tab_files, mixed_files), - "line_length": _measure_line_lengths(all_line_lengths), - "blank_lines": blank_lines, - "trailing_newline": { - "present": trailing_newline_present, - "absent": trailing_newline_absent, - }, - "trailing_whitespace": { - "files_with_trailing_ws": files_with_trailing_ws, - }, - } - - -def measure(repo_path: str, lang_filter: str | None = None) -> dict: - try: - repo = validate_repo(repo_path) - except ValueError as exc: - return {"error": str(exc), "script": "measure"} - - by_lang = _collect_files(repo, lang_filter) - result: dict = {} - - for lang, files in by_lang.items(): - if not files: - continue - - try: - result[lang] = _measure_lang(lang, files) - except Exception as exc: - result[lang] = {"error": str(exc)} - - return result - - -def main(argv: list[str] | None = None) -> int: - return run_command_main( - argv=argv, - description=__doc__, - command_fn=measure, - script_name="measure", - supports_lang=True, - ) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/agentskill/commands/scan.py b/agentskill/commands/scan.py deleted file mode 100644 index b28cd76..0000000 --- a/agentskill/commands/scan.py +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env python3 -"""Walk the repository directory tree. Produce an annotated file inventory. - -Outputs: - - tree: flat list of all source files with metadata - - summary: per-language file/line counts - - read_order: suggested reading order (entry points first, then by size) - -Usage: - python scripts/scan.py - python scripts/scan.py --lang python - python scripts/scan.py --pretty -""" - -import sys -from pathlib import Path - -from agentskill.common.fs import count_lines, validate_repo -from agentskill.common.languages import language_for_path -from agentskill.common.walk import walk_repo -from agentskill.lib.cli_entrypoint import run_command_main - -SKIP_EXTENSIONS: set[str] = { - ".pyc", - ".pyo", - ".pyd", - ".so", - ".dylib", - ".dll", - ".class", - ".jar", - ".war", - ".o", - ".a", - ".out", - ".png", - ".jpg", - ".jpeg", - ".gif", - ".svg", - ".ico", - ".webp", - ".woff", - ".woff2", - ".ttf", - ".eot", - ".pdf", - ".zip", - ".tar", - ".gz", - ".bz2", - ".xz", - ".lock", -} - -ENTRY_POINT_NAMES: set[str] = { - "main", - "cli", - "app", - "index", - "server", - "cmd", - "__main__", - "manage", - "wsgi", - "asgi", - "run", -} - - -def _is_entry_point(stem: str) -> bool: - return stem.lower() in ENTRY_POINT_NAMES - - -def scan(repo_path: str, lang_filter: str | None = None) -> dict: - try: - repo = validate_repo(repo_path) - except ValueError as exc: - return {"error": str(exc), "script": "scan"} - - tree: list[dict] = [] - depths: list[int] = [] - paths, _walk_stats = walk_repo(repo) - - for filepath in paths: - ext = filepath.suffix.lower() - - if ext in SKIP_EXTENSIONS: - continue - - spec = language_for_path(filepath) - language = spec.id if spec else None - - if not language: - continue - - if lang_filter and language != lang_filter: - continue - - rel_path = str(filepath.relative_to(repo)) - - try: - size_bytes = filepath.stat().st_size - except Exception: - size_bytes = 0 - - file_depth = len(filepath.relative_to(repo).parts) - line_count = count_lines(filepath) - depths.append(file_depth) - - tree.append( - { - "path": rel_path, - "type": "file", - "language": language, - "size_bytes": size_bytes, - "line_count": line_count, - "depth": file_depth, - } - ) - - by_language: dict[str, dict] = {} - for entry in tree: - lang = entry["language"] - - if lang not in by_language: - by_language[lang] = {"file_count": 0, "total_lines": 0} - - by_language[lang]["file_count"] += 1 - by_language[lang]["total_lines"] += entry["line_count"] - - max_depth = max(depths, default=0) - avg_depth = round(sum(depths) / len(depths), 1) if depths else 0.0 - - summary = { - "total_files": len(tree), - "by_language": by_language, - "max_depth": max_depth, - "avg_depth": avg_depth, - } - - entry_points = [e for e in tree if _is_entry_point(Path(e["path"]).stem)] - rest = [e for e in tree if not _is_entry_point(Path(e["path"]).stem)] - - entry_points.sort(key=lambda e: -e["line_count"]) - rest.sort(key=lambda e: (-e["line_count"], e["path"])) - - read_order = [e["path"] for e in entry_points + rest] - - return { - "tree": tree, - "summary": summary, - "read_order": read_order, - } - - -def main(argv: list[str] | None = None) -> int: - return run_command_main( - argv=argv, - description=__doc__, - command_fn=scan, - script_name="scan", - supports_lang=True, - ) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/agentskill/commands/symbols.py b/agentskill/commands/symbols.py deleted file mode 100644 index 12622d7..0000000 --- a/agentskill/commands/symbols.py +++ /dev/null @@ -1,1323 +0,0 @@ -#!/usr/bin/env python3 -"""Extract all symbol names from the codebase. Cluster by naming pattern. - -Detects codebase-specific conventions beyond standard language defaults: -recurring prefixes, suffixes, and naming idioms that appear 5+ times. - -Usage: - python scripts/symbols.py - python scripts/symbols.py --lang python - python scripts/symbols.py --pretty -""" - -import ast -import os -import re -import sys -from collections import Counter -from pathlib import Path - -from agentskill.common.constants import should_skip_dir -from agentskill.common.fs import read_text, validate_repo -from agentskill.common.languages import language_for_path -from agentskill.lib.cli_entrypoint import run_command_main - -MIN_NAME_LENGTH = 4 -MAX_AFFIX_LENGTH = 8 -MAX_AFFIX_EXAMPLES = 3 -MAX_AFFIX_CANDIDATES = 30 -MAX_AFFIXES_RETURNED = 10 - -SKIP_AFFIXES = { - "er", - "ed", - "ing", - "ion", - "al", - "tion", - "le", - "or", - "is", - "at", - "get", - "set", - "has", - "is_", - "_is", - "on", - "re", - "un", - "de", -} - - -def _collect_files(repo: Path, exts: list[str]) -> list[Path]: - found = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - fpath = Path(dirpath) / fn - suffix = fpath.suffix.lower() - - if suffix in exts: - found.append(fpath) - elif ".sh" in exts and ".bash" in exts: - spec = language_for_path(fpath) - - if spec and spec.id == "bash": - found.append(fpath) - - return found - - -def _classify(name: str) -> str: - if name.startswith("__") and name.endswith("__"): - return "dunder" - - if name.startswith("_"): - return "private" - - if name == name.upper() and "_" in name: - return "SCREAMING_SNAKE_CASE" - - if "_" in name: - return "snake_case" - - if name and name[0].isupper(): - return "PascalCase" - - if name and name[0].islower() and any(c.isupper() for c in name[1:]): - return "camelCase" - - return "other" - - -def _collect_affix_counts( - names: list[str], kind: str, min_len: int -) -> tuple[Counter, dict[str, list[str]]]: - """Count prefix or suffix occurrences across names. kind is 'prefix' or 'suffix'.""" - counts: Counter = Counter() - examples: dict[str, list[str]] = {} - - for name in names: - if len(name) < MIN_NAME_LENGTH or ( - name.startswith("__") and name.endswith("__") - ): - continue - - clean = name.lstrip("_") - - for length in range(min_len, min(MAX_AFFIX_LENGTH + 1, len(clean))): - affix = clean[:length] if kind == "prefix" else clean[-length:] - valid = affix.isalpha() or ( - "_" in affix - and (affix.endswith("_") if kind == "prefix" else affix.startswith("_")) - ) - if valid: - counts[affix] += 1 - examples.setdefault(affix, []) - - if len(examples[affix]) < MAX_AFFIX_EXAMPLES: - examples[affix].append(name) - - return counts, examples - - -def _affix_entries( - counts: Counter, - examples: dict[str, list[str]], - kind: str, - min_count: int, - min_len: int, -) -> list[dict]: - """Build result dicts for the top affix candidates.""" - entries = [] - - for affix, count in counts.most_common(MAX_AFFIX_CANDIDATES): - if count < min_count or affix.lower() in SKIP_AFFIXES or len(affix) < min_len: - continue - - if kind == "prefix": - pattern = ( - f"{affix}_ prefix" if not affix.endswith("_") else f"{affix} prefix" - ) - else: - pattern = ( - f"_{affix} suffix" if not affix.startswith("_") else f"{affix} suffix" - ) - - entries.append( - {"pattern": pattern, "count": count, "examples": examples.get(affix, [])} - ) - - return entries - - -def _dedupe_sorted(results: list[dict]) -> list[dict]: - """Deduplicate by pattern key, return top MAX_AFFIXES_RETURNED sorted by count.""" - seen: set[str] = set() - unique: list[dict] = [] - - for r in sorted(results, key=lambda x: -x["count"]): - if r["pattern"] not in seen: - seen.add(r["pattern"]) - unique.append(r) - - if len(unique) >= MAX_AFFIXES_RETURNED: - break - - return unique - - -def _find_affixes(names: list[str], min_count: int = 5, min_len: int = 2) -> list[dict]: - """Find recurring prefixes and suffixes appearing in 5+ names.""" - prefix_counts, prefix_examples = _collect_affix_counts(names, "prefix", min_len) - suffix_counts, suffix_examples = _collect_affix_counts(names, "suffix", min_len) - - results = _affix_entries( - prefix_counts, prefix_examples, "prefix", min_count, min_len - ) + _affix_entries(suffix_counts, suffix_examples, "suffix", min_count, min_len) - - return _dedupe_sorted(results) - - -def _pattern_summary(names: list[str]) -> dict: - if not names: - return {"total": 0, "patterns": {}, "codebase_specific": []} - - total = len(names) - counts = Counter(_classify(n) for n in names) - - patterns = { - k: {"count": v, "pct": round(v / total * 100, 1)} - for k, v in sorted(counts.items(), key=lambda x: -x[1]) - } - - return { - "total": total, - "patterns": patterns, - "codebase_specific": _find_affixes(names), - } - - -def _extract_python(files: list[Path]) -> dict: - functions: list[str] = [] - classes: list[str] = [] - constants: list[str] = [] - private_single: list[str] = [] - private_double: list[str] = [] - file_names: list[str] = [] - - for fpath in files: - file_names.append(fpath.stem) - - try: - source = read_text(fpath) - tree = ast.parse(source) - except Exception: - continue - - for node in ast.walk(tree): - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - name = node.name - functions.append(name) - - if name.startswith("__") and not name.endswith("__"): - private_double.append(name) - elif name.startswith("_"): - private_single.append(name) - - elif isinstance(node, ast.ClassDef): - classes.append(node.name) - - elif ( - isinstance(node, ast.Assign) - and isinstance(node.targets[0], ast.Name) - and node.col_offset == 0 - ): - target = node.targets[0].id - if target == target.upper() and "_" in target: - constants.append(target) - - return { - "functions": _pattern_summary(functions), - "classes": _pattern_summary(classes), - "constants": _pattern_summary(constants), - "private_members": { - "single_underscore": len(private_single), - "double_underscore": len(private_double), - "examples": (private_single + private_double)[:10], - }, - "files": _pattern_summary(file_names), - } - - -def _extract_ts(files: list[Path], lang: str) -> dict: - export_func_re = re.compile( - r"(?:^|\s)(?:export\s+)(?:async\s+)?function\s+(\w+)", - re.MULTILINE, - ) - default_func_re = re.compile( - r"(?:^|\s)(?:export\s+)?(?:default\s+)(?:async\s+)?function\s+(\w+)", - re.MULTILINE, - ) - plain_func_re = re.compile( - r"(?:^|\s)(?:async\s+)?function\s+(\w+)", - re.MULTILINE, - ) - - export_class_re = re.compile( - r"(?:^|\s)(?:export\s+)(?:abstract\s+)?class\s+(\w+)", - re.MULTILINE, - ) - default_class_re = re.compile( - r"(?:^|\s)(?:export\s+)?(?:default\s+)(?:abstract\s+)?class\s+(\w+)", - re.MULTILINE, - ) - plain_class_re = re.compile( - r"(?:^|\s)(?:abstract\s+)?class\s+(\w+)", - re.MULTILINE, - ) - - export_iface_re = re.compile( - r"(?:^|\s)(?:export\s+)?interface\s+(\w+)", - re.MULTILINE, - ) - export_type_re = re.compile( - r"(?:^|\s)(?:export\s+)?type\s+(\w+)\s*=", - re.MULTILINE, - ) - - export_arrow_re = re.compile( - r"(?:^|\s)export\s+const\s+(\w+)\s*=\s*(?:async\s+)?\(", - re.MULTILINE, - ) - plain_arrow_re = re.compile( - r"^\s*const\s+(\w+)\s*=\s*(?:async\s+)?\(", - re.MULTILINE, - ) - export_func_expr_re = re.compile( - r"(?:^|\s)export\s+const\s+(\w+)\s*=\s*function", - re.MULTILINE, - ) - - const_re = re.compile( - r"(?:^|\s)export\s+const\s+([A-Z_][A-Z0-9_]*)\s*[=:]", - re.MULTILINE, - ) - - functions: list[str] = [] - classes: list[str] = [] - interfaces: list[str] = [] - types: list[str] = [] - constants: list[str] = [] - file_names: list[str] = [] - - for fpath in files: - file_names.append(fpath.stem.replace(".test", "").replace(".spec", "")) - - try: - source = read_text(fpath) - except Exception: - continue - - for m in export_func_re.finditer(source): - functions.append(m.group(1)) - for m in default_func_re.finditer(source): - functions.append(m.group(1)) - for m in plain_func_re.finditer(source): - functions.append(m.group(1)) - - for m in export_class_re.finditer(source): - classes.append(m.group(1)) - for m in default_class_re.finditer(source): - classes.append(m.group(1)) - for m in plain_class_re.finditer(source): - classes.append(m.group(1)) - - for m in export_iface_re.finditer(source): - interfaces.append(m.group(1)) - for m in export_type_re.finditer(source): - types.append(m.group(1)) - - for m in export_arrow_re.finditer(source): - functions.append(m.group(1)) - for m in plain_arrow_re.finditer(source): - functions.append(m.group(1)) - for m in export_func_expr_re.finditer(source): - functions.append(m.group(1)) - - for m in const_re.finditer(source): - constants.append(m.group(1)) - - result: dict = { - "functions": _pattern_summary(functions), - "classes": _pattern_summary(classes), - "constants": _pattern_summary(constants), - "files": _pattern_summary(file_names), - } - - if interfaces: - result["interfaces"] = _pattern_summary(interfaces) - - if types: - result["types"] = _pattern_summary(types) - - return result - - -def _strip_go_comments(source: str) -> str: - source = re.sub(r"//.*", "", source) - source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) - return source - - -def _extract_go(files: list[Path]) -> dict: - func_re = re.compile(r"^func\s+(?:\(\w+\s+\*?\w+\)\s+)?(\w+)\s*\(") - method_re = re.compile(r"^func\s+\((\w+)\s+\*?(\w+)\)\s+(\w+)\s*\(") - type_struct_re = re.compile(r"^type\s+(\w+)\s+struct") - type_iface_re = re.compile(r"^type\s+(\w+)\s+interface") - - type_alias_re = re.compile( - r"^type\s+(\w+)\s+(?:string|int|float64|bool|error|byte|rune|any)\b" - ) - - const_re = re.compile(r"^\s+(\w+)\s*(?:=|[A-Z])") - var_re = re.compile(r"^var\s+(\w+)") - - functions: list[str] = [] - methods: list[str] = [] - structs: list[str] = [] - interfaces: list[str] = [] - type_aliases: list[str] = [] - constants: list[str] = [] - variables: list[str] = [] - file_names: list[str] = [] - - for fpath in files: - file_names.append(fpath.stem) - - try: - source = _strip_go_comments(read_text(fpath)) - except Exception: - continue - - lines = source.splitlines() - constants.extend(_extract_go_constants(lines, const_re)) - - for line in lines: - m = func_re.match(line) - if m: - functions.append(m.group(1)) - - m = method_re.match(line) - if m: - methods.append(m.group(3)) - - m = type_struct_re.match(line) - if m: - structs.append(m.group(1)) - - m = type_iface_re.match(line) - if m: - interfaces.append(m.group(1)) - - m = type_alias_re.match(line) - if m: - type_aliases.append(m.group(1)) - - m = var_re.match(line) - if m: - variables.append(m.group(1)) - - result: dict = { - "functions": _pattern_summary(functions), - "methods": _pattern_summary(methods), - "types": _pattern_summary(structs), - "constants": _pattern_summary(constants), - "files": _pattern_summary(file_names), - } - - if interfaces: - result["interfaces"] = _pattern_summary(interfaces) - - if type_aliases: - result["type_aliases"] = _pattern_summary(type_aliases) - - if variables: - result["variables"] = _pattern_summary(variables) - - return result - - -def _extract_go_constants(lines: list[str], const_re: re.Pattern) -> list[str]: - constants: list[str] = [] - in_const = False - - for line in lines: - stripped = line.strip() - - if stripped.startswith("const ("): - in_const = True - continue - - if in_const and stripped == ")": - in_const = False - continue - - if in_const: - m = const_re.match(line) - if m: - constants.append(m.group(1)) - - return constants - - -def _strip_rust_comments(source: str) -> str: - source = re.sub(r"//.*", "", source) - source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) - return source - - -def _strip_jvm_comments(source: str) -> str: - source = re.sub(r"//.*", "", source) - source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) - return source - - -def _strip_c_family_comments(source: str) -> str: - source = re.sub(r"//.*", "", source) - source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) - return source - - -def _strip_swift_comments(source: str) -> str: - source = re.sub(r"//.*", "", source) - source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) - return source - - -def _extract_java(files: list[Path]) -> dict: - decl_re = re.compile( - r"^\s*(?P(?:public|private|protected|static|final|abstract)\s+)*" - r"(?Pclass|interface|enum|@interface)\s+" - r"(?P[A-Za-z_]\w*)", - re.MULTILINE, - ) - - method_re = re.compile( - r"^\s*(?P(?:public|private|protected|static|final|abstract|synchronized)\s+)*" - r"(?:(?P[A-Za-z_][\w<>\[\], ?]*)\s+)?" - r"(?P[A-Za-z_]\w*)\s*\(", - re.MULTILINE, - ) - - classes: list[str] = [] - interfaces: list[str] = [] - enums: list[str] = [] - annotations: list[str] = [] - methods: list[str] = [] - constructors: list[str] = [] - constants: list[str] = [] - file_names: list[str] = [] - declared_types: set[str] = set() - - for fpath in files: - file_names.append(fpath.stem) - - try: - source = _strip_jvm_comments(read_text(fpath)) - except Exception: - continue - - for match in decl_re.finditer(source): - kind = match.group("kind") - name = match.group("name") - declared_types.add(name) - - if kind == "class": - classes.append(name) - elif kind == "interface": - interfaces.append(name) - elif kind == "enum": - enums.append(name) - elif kind == "@interface": - annotations.append(name) - - for match in method_re.finditer(source): - name = match.group("name") - return_type = match.group("return") - - if name in {"if", "for", "while", "switch", "catch", "return", "new"}: - continue - - if return_type is None and name in declared_types: - constructors.append(name) - elif return_type is not None: - methods.append(name) - - for match in re.finditer( - r"^\s*(?:public|private|protected)?\s*static\s+final\s+[\w<>\[\], ?]+\s+([A-Z_][A-Z0-9_]*)\b", - source, - re.MULTILINE, - ): - constants.append(match.group(1)) - - result: dict = { - "classes": _pattern_summary(classes), - "methods": _pattern_summary(methods), - "constants": _pattern_summary(constants), - "files": _pattern_summary(file_names), - } - - if interfaces: - result["interfaces"] = _pattern_summary(interfaces) - - if enums: - result["enums"] = _pattern_summary(enums) - - if annotations: - result["annotations"] = _pattern_summary(annotations) - - if constructors: - result["constructors"] = _pattern_summary(constructors) - - return result - - -def _extract_kotlin(files: list[Path]) -> dict: - type_re = re.compile( - r"^\s*(?P(?:public|private|protected|internal)\s+)*enum\s+class\s+(?P[A-Za-z_]\w*)|" - r"^\s*(?P(?:public|private|protected|internal|open|abstract|sealed|data)\s+)*" - r"(?Pclass|interface|object)\s+(?P[A-Za-z_]\w*)", - re.MULTILINE, - ) - - function_re = re.compile( - r"^\s*(?P(?:public|private|protected|internal|override|suspend|inline|tailrec|operator|infix)\s+)*" - r"fun\s+(?:[A-Za-z_][\w.<>?, ]+\.)?(?P[A-Za-z_]\w*)\s*\(", - re.MULTILINE, - ) - - property_re = re.compile( - r"^\s*(?P(?:public|private|protected|internal|lateinit|override)\s+)*" - r"(?Pconst\s+)?(?:val|var)\s+(?P[A-Za-z_]\w*)\b", - re.MULTILINE, - ) - - classes: list[str] = [] - interfaces: list[str] = [] - objects: list[str] = [] - enums: list[str] = [] - functions: list[str] = [] - properties: list[str] = [] - constants: list[str] = [] - file_names: list[str] = [] - - for fpath in files: - file_names.append(fpath.stem) - - try: - source = _strip_jvm_comments(read_text(fpath)) - except Exception: - continue - - for match in type_re.finditer(source): - kind = match.group("kind") - - if match.group("enum_name"): - enums.append(match.group("enum_name")) - continue - - name = match.group("name") - if not kind or not name: - continue - - if kind == "class": - classes.append(name) - elif kind == "interface": - interfaces.append(name) - elif kind == "object": - objects.append(name) - - for match in function_re.finditer(source): - functions.append(match.group("name")) - - for match in property_re.finditer(source): - name = match.group("name") - - if match.group("const"): - constants.append(name) - else: - properties.append(name) - - result: dict = { - "classes": _pattern_summary(classes), - "functions": _pattern_summary(functions), - "constants": _pattern_summary(constants), - "files": _pattern_summary(file_names), - } - - if interfaces: - result["interfaces"] = _pattern_summary(interfaces) - - if objects: - result["objects"] = _pattern_summary(objects) - - if enums: - result["enums"] = _pattern_summary(enums) - - if properties: - result["properties"] = _pattern_summary(properties) - - return result - - -def _extract_csharp(files: list[Path]) -> dict: - type_re = re.compile( - r"^\s*(?:public|private|protected|internal)?\s*(?:abstract\s+|static\s+|sealed\s+|partial\s+)?" - r"(class|interface|struct|enum|record)\s+([A-Za-z_]\w*)", - re.MULTILINE, - ) - - method_re = re.compile( - r"^\s*(?:public|private|protected|internal)\s+(?:static\s+|virtual\s+|override\s+|async\s+)?" - r"[\w<>\[\], ?]+\s+([A-Za-z_]\w*)\s*\(", - re.MULTILINE, - ) - - classes: list[str] = [] - interfaces: list[str] = [] - structs: list[str] = [] - enums: list[str] = [] - records: list[str] = [] - methods: list[str] = [] - file_names: list[str] = [] - - for fpath in files: - file_names.append(fpath.stem) - - try: - source = _strip_c_family_comments(read_text(fpath)) - except Exception: - continue - - for kind, name in type_re.findall(source): - if kind == "class": - classes.append(name) - elif kind == "interface": - interfaces.append(name) - elif kind == "struct": - structs.append(name) - elif kind == "enum": - enums.append(name) - elif kind == "record": - records.append(name) - - for name in method_re.findall(source): - if name not in {"if", "for", "while", "switch", "catch", "foreach"}: - methods.append(name) - - result: dict = { - "classes": _pattern_summary(classes), - "methods": _pattern_summary(methods), - "files": _pattern_summary(file_names), - } - - if interfaces: - result["interfaces"] = _pattern_summary(interfaces) - - if structs: - result["structs"] = _pattern_summary(structs) - - if enums: - result["enums"] = _pattern_summary(enums) - - if records: - result["records"] = _pattern_summary(records) - - return result - - -def _extract_c(files: list[Path]) -> dict: - source_files = [f for f in files if f.suffix.lower() == ".c"] - all_files = files if source_files else files - - function_re = re.compile( - r"^\s*(?!if\b|for\b|while\b|switch\b|return\b)(?:[A-Za-z_][\w\s\*]+)\s+([A-Za-z_]\w*)\s*\([^;]*\)\s*\{", - re.MULTILINE, - ) - - struct_re = re.compile(r"^\s*struct\s+([A-Za-z_]\w*)\s*\{", re.MULTILINE) - enum_re = re.compile(r"^\s*enum\s+([A-Za-z_]\w*)\s*\{", re.MULTILINE) - - typedef_re = re.compile( - r"^\s*typedef\s+(?:struct|enum|union)?\s*[A-Za-z_]*\s*([A-Za-z_]\w*)\s*;", - re.MULTILINE, - ) - - macro_re = re.compile(r"^\s*#define\s+([A-Z_][A-Z0-9_]*)\b", re.MULTILINE) - - functions: list[str] = [] - structs: list[str] = [] - enums: list[str] = [] - typedefs: list[str] = [] - macros: list[str] = [] - file_names: list[str] = [] - - for fpath in all_files: - file_names.append(fpath.stem) - - try: - source = _strip_c_family_comments(read_text(fpath)) - except Exception: - continue - - if fpath.suffix.lower() == ".c": - functions.extend(function_re.findall(source)) - - structs.extend(struct_re.findall(source)) - enums.extend(enum_re.findall(source)) - typedefs.extend(typedef_re.findall(source)) - macros.extend(macro_re.findall(source)) - - result: dict = { - "functions": _pattern_summary(functions), - "files": _pattern_summary(file_names), - } - - if structs: - result["structs"] = _pattern_summary(structs) - - if enums: - result["enums"] = _pattern_summary(enums) - - if typedefs: - result["typedefs"] = _pattern_summary(typedefs) - - if macros: - result["macros"] = _pattern_summary(macros) - - return result - - -def _extract_cpp(files: list[Path]) -> dict: - source_files = [ - f - for f in files - if f.suffix.lower() in {".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"} - ] - - namespace_re = re.compile(r"^\s*namespace\s+([A-Za-z_]\w*)\s*\{", re.MULTILINE) - - template_re = re.compile( - r"^\s*template\s*<[^>]+>\s*(?:class|struct)?\s*([A-Za-z_]\w*)?", - re.MULTILINE, - ) - - class_re = re.compile(r"^\s*class\s+([A-Za-z_]\w*)", re.MULTILINE) - struct_re = re.compile(r"^\s*struct\s+([A-Za-z_]\w*)", re.MULTILINE) - enum_re = re.compile(r"^\s*enum(?:\s+class)?\s+([A-Za-z_]\w*)", re.MULTILINE) - - function_re = re.compile( - r"^\s*(?!if\b|for\b|while\b|switch\b|return\b)(?:[A-Za-z_][\w:\s<>\*&]+)\s+([A-Za-z_]\w*)\s*\([^;]*\)\s*\{", - re.MULTILINE, - ) - - namespaces: list[str] = [] - classes: list[str] = [] - structs: list[str] = [] - enums: list[str] = [] - functions: list[str] = [] - templates: list[str] = [] - file_names: list[str] = [] - - for fpath in source_files: - file_names.append(fpath.stem) - - try: - source = _strip_c_family_comments(read_text(fpath)) - except Exception: - continue - - namespaces.extend(namespace_re.findall(source)) - classes.extend(class_re.findall(source)) - structs.extend(struct_re.findall(source)) - enums.extend(enum_re.findall(source)) - functions.extend(function_re.findall(source)) - - for name in template_re.findall(source): - if name: - templates.append(name) - - result: dict = { - "functions": _pattern_summary(functions), - "files": _pattern_summary(file_names), - } - - if namespaces: - result["namespaces"] = _pattern_summary(namespaces) - - if classes: - result["classes"] = _pattern_summary(classes) - - if structs: - result["structs"] = _pattern_summary(structs) - - if enums: - result["enums"] = _pattern_summary(enums) - - if templates: - result["templates"] = _pattern_summary(templates) - - return result - - -def _extract_ruby(files: list[Path]) -> dict: - source_files = files - module_re = re.compile(r"^\s*module\s+([A-Za-z_]\w*)", re.MULTILINE) - class_re = re.compile(r"^\s*class\s+([A-Za-z_]\w*)", re.MULTILINE) - method_re = re.compile(r"^\s*def\s+([A-Za-z_]\w*[!?=]?)", re.MULTILINE) - class_method_re = re.compile(r"^\s*def\s+self\.([A-Za-z_]\w*[!?=]?)", re.MULTILINE) - - modules: list[str] = [] - classes: list[str] = [] - methods: list[str] = [] - class_methods: list[str] = [] - file_names: list[str] = [] - - for fpath in source_files: - file_names.append(fpath.stem) - - try: - source = re.sub(r"#.*", "", read_text(fpath)) - except Exception: - continue - - modules.extend(module_re.findall(source)) - classes.extend(class_re.findall(source)) - class_methods.extend(class_method_re.findall(source)) - - for name in method_re.findall(source): - if not any(name == method for method in class_methods): - methods.append(name) - - result: dict = { - "classes": _pattern_summary(classes), - "methods": _pattern_summary(methods), - "files": _pattern_summary(file_names), - } - - if modules: - result["modules"] = _pattern_summary(modules) - - if class_methods: - result["class_methods"] = _pattern_summary(class_methods) - - return result - - -def _extract_php(files: list[Path]) -> dict: - type_re = re.compile( - r"^\s*(class|interface|trait|enum)\s+([A-Za-z_]\w*)", - re.MULTILINE, - ) - - function_re = re.compile(r"^\s*function\s+([A-Za-z_]\w*)\s*\(", re.MULTILINE) - - method_re = re.compile( - r"^\s*(?:public|protected|private)\s+function\s+([A-Za-z_]\w*)\s*\(", - re.MULTILINE, - ) - - classes: list[str] = [] - interfaces: list[str] = [] - traits: list[str] = [] - enums: list[str] = [] - functions: list[str] = [] - methods: list[str] = [] - file_names: list[str] = [] - - for fpath in files: - file_names.append(fpath.stem) - - try: - source = _strip_c_family_comments(read_text(fpath)) - except Exception: - continue - - for kind, name in type_re.findall(source): - if kind == "class": - classes.append(name) - elif kind == "interface": - interfaces.append(name) - elif kind == "trait": - traits.append(name) - elif kind == "enum": - enums.append(name) - - methods.extend(method_re.findall(source)) - - for name in function_re.findall(source): - if name not in methods: - functions.append(name) - - result: dict = { - "classes": _pattern_summary(classes), - "functions": _pattern_summary(functions), - "methods": _pattern_summary(methods), - "files": _pattern_summary(file_names), - } - - if interfaces: - result["interfaces"] = _pattern_summary(interfaces) - - if traits: - result["traits"] = _pattern_summary(traits) - - if enums: - result["enums"] = _pattern_summary(enums) - - return result - - -def _extract_bash(files: list[Path]) -> dict: - function_re = re.compile( - r"^\s*(?:function\s+)?([A-Za-z_]\w*)\s*\(\)\s*\{", re.MULTILINE - ) - - functions: list[str] = [] - file_names: list[str] = [] - - for fpath in files: - file_names.append(fpath.stem or fpath.name) - - try: - source = read_text(fpath) - except Exception: - continue - - functions.extend(function_re.findall(source)) - - return { - "functions": _pattern_summary(functions), - "files": _pattern_summary(file_names), - } - - -def _extract_swift(files: list[Path]) -> dict: - type_re = re.compile( - r"^\s*(?:public|open|internal|private|fileprivate)?\s*(?:final\s+)?(struct|class|enum|protocol)\s+([A-Za-z_]\w*)", - re.MULTILINE, - ) - - function_re = re.compile( - r"^\s*(?:public|open|internal|private|fileprivate)?\s*func\s+([A-Za-z_]\w*)\s*\(", - re.MULTILINE, - ) - - extension_re = re.compile(r"^\s*extension\s+([A-Za-z_]\w*)", re.MULTILINE) - - structs: list[str] = [] - classes: list[str] = [] - enums: list[str] = [] - protocols: list[str] = [] - functions: list[str] = [] - extensions: list[str] = [] - file_names: list[str] = [] - - for fpath in files: - file_names.append(fpath.stem) - - try: - source = _strip_swift_comments(read_text(fpath)) - except Exception: - continue - - for kind, name in type_re.findall(source): - if kind == "struct": - structs.append(name) - elif kind == "class": - classes.append(name) - elif kind == "enum": - enums.append(name) - elif kind == "protocol": - protocols.append(name) - - functions.extend(function_re.findall(source)) - extensions.extend(extension_re.findall(source)) - - result: dict = { - "functions": _pattern_summary(functions), - "files": _pattern_summary(file_names), - } - - if structs: - result["structs"] = _pattern_summary(structs) - - if classes: - result["classes"] = _pattern_summary(classes) - - if enums: - result["enums"] = _pattern_summary(enums) - - if protocols: - result["protocols"] = _pattern_summary(protocols) - - if extensions: - result["extensions"] = _pattern_summary(extensions) - - return result - - -def _is_objectivec_header(path: Path) -> bool: - if path.suffix.lower() != ".h": - return False - - content = read_text(path) - - return any( - marker in content - for marker in ("@interface", "@protocol", "@implementation", "#import") - ) - - -def _collect_objectivec_files(repo: Path) -> list[Path]: - found: list[Path] = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - fpath = Path(dirpath) / fn - suffix = fpath.suffix.lower() - - if suffix in {".m", ".mm"} or ( - suffix == ".h" and _is_objectivec_header(fpath) - ): - found.append(fpath) - - return found - - -def _extract_objectivec(files: list[Path]) -> dict: - interface_re = re.compile(r"^\s*@interface\s+([A-Za-z_]\w*)", re.MULTILINE) - - implementation_re = re.compile( - r"^\s*@implementation\s+([A-Za-z_]\w*)", re.MULTILINE - ) - - protocol_re = re.compile(r"^\s*@protocol\s+([A-Za-z_]\w*)", re.MULTILINE) - method_re = re.compile(r"^\s*-\s*\([^)]+\)\s*([A-Za-z_]\w*)", re.MULTILINE) - class_method_re = re.compile(r"^\s*\+\s*\([^)]+\)\s*([A-Za-z_]\w*)", re.MULTILINE) - - enum_re = re.compile( - r"NS_ENUM\s*\([^)]+,\s*([A-Za-z_]\w*)\)|^\s*typedef\s+enum\s+[A-Za-z_]*\s*\{", - re.MULTILINE, - ) - - interfaces: list[str] = [] - implementations: list[str] = [] - protocols: list[str] = [] - methods: list[str] = [] - class_methods: list[str] = [] - enums: list[str] = [] - file_names: list[str] = [] - - for fpath in files: - file_names.append(fpath.stem) - - try: - source = _strip_c_family_comments(read_text(fpath)) - except Exception: - continue - - interfaces.extend(interface_re.findall(source)) - implementations.extend(implementation_re.findall(source)) - protocols.extend(protocol_re.findall(source)) - methods.extend(method_re.findall(source)) - class_methods.extend(class_method_re.findall(source)) - - for match in enum_re.findall(source): - if match: - enums.append(match) - - result: dict = { - "files": _pattern_summary(file_names), - "methods": _pattern_summary(methods), - } - - if interfaces: - result["interfaces"] = _pattern_summary(interfaces) - - if implementations: - result["implementations"] = _pattern_summary(implementations) - - if protocols: - result["protocols"] = _pattern_summary(protocols) - - if class_methods: - result["class_methods"] = _pattern_summary(class_methods) - - if enums: - result["enums"] = _pattern_summary(enums) - - return result - - -def _extract_rust(files: list[Path]) -> dict: - func_re = re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)", re.MULTILINE) - struct_re = re.compile(r"^\s*(?:pub\s+)?struct\s+(\w+)", re.MULTILINE) - enum_re = re.compile(r"^\s*(?:pub\s+)?enum\s+(\w+)", re.MULTILINE) - trait_re = re.compile(r"^\s*(?:pub\s+)?trait\s+(\w+)", re.MULTILINE) - impl_re = re.compile(r"^\s*impl\s+(?:(?:\w+)\s+for\s+)?(\w+)", re.MULTILINE) - const_re = re.compile(r"^\s*(?:pub\s+)?const\s+(\w+)", re.MULTILINE) - static_re = re.compile(r"^\s*(?:pub\s+)?static\s+(\w+)", re.MULTILINE) - - functions: list[str] = [] - structs: list[str] = [] - enums: list[str] = [] - traits: list[str] = [] - impls: list[str] = [] - constants: list[str] = [] - statics: list[str] = [] - file_names: list[str] = [] - - for fpath in files: - file_names.append(fpath.stem) - - try: - source = _strip_rust_comments(read_text(fpath)) - except Exception: - continue - - for m in func_re.finditer(source): - functions.append(m.group(1)) - - for m in struct_re.finditer(source): - structs.append(m.group(1)) - - for m in enum_re.finditer(source): - enums.append(m.group(1)) - - for m in trait_re.finditer(source): - traits.append(m.group(1)) - - for m in impl_re.finditer(source): - impls.append(m.group(1)) - - for m in const_re.finditer(source): - constants.append(m.group(1)) - - for m in static_re.finditer(source): - statics.append(m.group(1)) - - result: dict = { - "functions": _pattern_summary(functions), - "structs": _pattern_summary(structs), - "enums": _pattern_summary(enums), - "constants": _pattern_summary(constants), - "files": _pattern_summary(file_names), - } - - if traits: - result["traits"] = _pattern_summary(traits) - - if impls: - result["impls"] = _pattern_summary(impls) - - if statics: - result["statics"] = _pattern_summary(statics) - - return result - - -def extract_symbols(repo_path: str, lang_filter: str | None = None) -> dict: - try: - repo = validate_repo(repo_path) - except ValueError as exc: - return {"error": str(exc), "script": "symbols"} - - result: dict = {} - - def _run(lang: str, exts: list[str], extractor): - files = _collect_files(repo, exts) - - if not files: - return - - try: - result[lang] = extractor(files) - except Exception as exc: - result[lang] = {"error": str(exc)} - - if not lang_filter or lang_filter == "python": - _run("python", [".py"], _extract_python) - - if not lang_filter or lang_filter in ("typescript", "javascript"): - lang = lang_filter or "typescript" - _run( - lang, - [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"], - lambda files: _extract_ts(files, lang), - ) - - if not lang_filter or lang_filter == "go": - _run("go", [".go"], _extract_go) - - if not lang_filter or lang_filter == "rust": - _run("rust", [".rs"], _extract_rust) - - if not lang_filter or lang_filter == "java": - _run("java", [".java"], _extract_java) - - if not lang_filter or lang_filter == "kotlin": - _run("kotlin", [".kt", ".kts"], _extract_kotlin) - - if not lang_filter or lang_filter == "csharp": - _run("csharp", [".cs"], _extract_csharp) - - if not lang_filter or lang_filter == "c": - _run("c", [".c", ".h"], _extract_c) - - if not lang_filter or lang_filter == "cpp": - _run("cpp", [".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"], _extract_cpp) - - if not lang_filter or lang_filter == "ruby": - _run("ruby", [".rb"], _extract_ruby) - - if not lang_filter or lang_filter == "php": - _run("php", [".php"], _extract_php) - - if not lang_filter or lang_filter == "bash": - _run("bash", [".sh", ".bash"], _extract_bash) - - if not lang_filter or lang_filter == "swift": - _run("swift", [".swift"], _extract_swift) - - if not lang_filter or lang_filter == "objectivec": - files = _collect_objectivec_files(repo) - - if files: - try: - result["objectivec"] = _extract_objectivec(files) - except Exception as exc: - result["objectivec"] = {"error": str(exc)} - - return result - - -def main(argv: list[str] | None = None) -> int: - return run_command_main( - argv=argv, - description=__doc__, - command_fn=extract_symbols, - script_name="symbols", - supports_lang=True, - ) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/agentskill/commands/tests.py b/agentskill/commands/tests.py deleted file mode 100644 index 9cf3c7e..0000000 --- a/agentskill/commands/tests.py +++ /dev/null @@ -1,1663 +0,0 @@ -#!/usr/bin/env python3 -"""Map test files to source files. Characterize test structure and framework usage. - -Identifies what a representative test looks like so an agent can follow the same pattern. - -Usage: - python scripts/tests.py - python scripts/tests.py --pretty -""" - -import ast -import json -import os -import re -import sys -from collections import Counter -from pathlib import Path - -from agentskill.common.constants import ( - MAKEFILE_NAMES, - TEST_STRUCTURE_SOURCE_ROOTS, - TOP_LEVEL_TEST_DIRS, - should_skip_dir, -) -from agentskill.common.fs import count_lines, read_text, validate_repo -from agentskill.common.languages import ( - is_test_path, - language_by_id, - language_for_extension, - language_for_path, -) -from agentskill.lib.cli_entrypoint import run_command_main - -FRAMEWORK_DETECTION_SAMPLE = 5 -NAMING_DETECTION_SAMPLE = 10 -MAX_FIXTURE_NAMES = 20 - -FRAMEWORK_RUN_DEFAULTS = { - "pytest": "pytest", - "unittest": "python -m unittest discover", - "jest": "jest", - "vitest": "vitest", - "mocha": "mocha", - "xunit": "dotnet test", - "nunit": "dotnet test", - "mstest": "dotnet test", - "gtest": "ctest", - "catch2": "ctest", - "unity": "ctest", - "cmocka": "ctest", -} - - -def _required_extensions(language_id: str) -> tuple[str, ...]: - spec = language_by_id(language_id) - - if spec is None: - raise ValueError(f"missing language spec: {language_id}") - - return spec.extensions - - -TS_JS_LANGUAGE_IDS = {"typescript", "javascript"} - -C_FAMILY_SOURCE_EXTENSIONS = { - "c": frozenset(ext for ext in _required_extensions("c") if ext != ".h"), - "cpp": frozenset( - ext for ext in _required_extensions("cpp") if ext not in {".hpp", ".hh", ".hxx"} - ), -} - -OBJECTIVEC_SOURCE_EXTENSIONS = frozenset(_required_extensions("objectivec")) - -JVM_TEST_FRAMEWORKS = { - "org.junit.Test": "junit", - "org.junit.jupiter.api.Test": "junit", - "org.junit.jupiter.params.ParameterizedTest": "junit", - "org.testng.annotations.Test": "testng", - "kotlin.test": "kotlin-test", -} - -RUBY_TEST_FRAMEWORKS = { - "RSpec.describe": "rspec", - 'require "minitest/autorun"': "minitest", - "Minitest::Test": "minitest", -} - -PHP_TEST_FRAMEWORKS = { - "PHPUnit\\Framework\\TestCase": "phpunit", - "extends TestCase": "phpunit", -} - -SHELL_TEST_FRAMEWORKS = { - "@test": "bats", - ".bats": "bats", -} - -SWIFT_TEST_FRAMEWORKS = { - "import XCTest": "xctest", -} - -OBJC_TEST_FRAMEWORKS = { - "": "xctest", - "XCTestCase": "xctest", -} - -CSHARP_TEST_FRAMEWORKS = { - "using Xunit;": "xunit", - "using NUnit.Framework;": "nunit", - "using Microsoft.VisualStudio.TestTools.UnitTesting;": "mstest", - "[Fact]": "xunit", - "[Theory]": "xunit", - "[TestCase]": "nunit", - "[TestMethod]": "mstest", -} - -C_CPP_TEST_FRAMEWORKS = { - "gtest/gtest.h": "gtest", - "TEST(": "gtest", - "catch2/catch_test_macros.hpp": "catch2", - "TEST_CASE(": "catch2", - "unity.h": "unity", - "cmocka.h": "cmocka", -} - - -def _most_common(lst: list[str]) -> str | None: - if not lst: - return None - - return Counter(lst).most_common(1)[0][0] - - -def _count_lines(path: Path) -> int: - return count_lines(path) - - -def _has_language_suffix(filename: str, language_id: str) -> bool: - spec = language_for_extension(Path(filename).suffix.lower()) - return spec is not None and spec.id == language_id - - -def _has_any_language_suffix(filename: str, language_ids: set[str]) -> bool: - spec = language_for_extension(Path(filename).suffix.lower()) - return spec is not None and spec.id in language_ids - - -def _is_fixture_decorator(decorator: ast.expr) -> bool: - """Return True if an AST decorator node is a pytest fixture.""" - dec_str = ast.unparse(decorator) if hasattr(ast, "unparse") else "" - - return ( - "fixture" in dec_str - or (isinstance(decorator, ast.Attribute) and decorator.attr == "fixture") - or (isinstance(decorator, ast.Name) and decorator.id == "fixture") - ) - - -def _ts_framework_from_deps(test_cmd: str, dev_deps: dict) -> str | None: - """Return the TS test framework name inferred from scripts and devDependencies.""" - for name in ("jest", "vitest", "mocha"): - if name in test_cmd or name in dev_deps: - return name - - return None - - -def _mirrors_source_tree( - test_dirs: Counter, most_common_dir: str, repo: Path, src_root: str -) -> bool: - """Return True if the test directory structure mirrors a source root.""" - for d in test_dirs: - if d.startswith(most_common_dir): - sub = d[len(most_common_dir) :].lstrip(os.sep) - - if sub and (repo / src_root / sub).exists(): - return True - - return False - - -def _collect_python_files(repo: Path) -> tuple[list[Path], list[Path]]: - test_files: list[Path] = [] - source_files: list[Path] = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - if not _has_language_suffix(fn, "python"): - continue - - fpath = Path(dirpath) / fn - stem = Path(fn).stem - - if ( - stem.startswith("test_") - or stem.endswith("_test") - or "tests" in Path(dirpath).parts - or "test" in Path(dirpath).parts - ): - test_files.append(fpath) - else: - source_files.append(fpath) - - return test_files, source_files - - -def _collect_ts_files(repo: Path) -> tuple[list[Path], list[Path]]: - test_files: list[Path] = [] - source_files: list[Path] = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - if not _has_any_language_suffix(fn, TS_JS_LANGUAGE_IDS): - continue - - fpath = Path(dirpath) / fn - stem = Path(fn).stem - - if ( - ".test." in fn - or ".spec." in fn - or stem.endswith("-test") - or stem.endswith("-spec") - or "__tests__" in Path(dirpath).parts - or "tests" in Path(dirpath).parts - ): - test_files.append(fpath) - else: - source_files.append(fpath) - - return test_files, source_files - - -def _detect_python_framework(repo: Path, test_files: list[Path]) -> str: - pyproject = repo / "pyproject.toml" - - if pyproject.exists() and "[tool.pytest" in read_text(pyproject): - return "pytest" - - if (repo / "pytest.ini").exists() or (repo / "conftest.py").exists(): - return "pytest" - - setup_cfg = repo / "setup.cfg" - - if setup_cfg.exists() and "[tool:pytest]" in read_text(setup_cfg): - return "pytest" - - for fpath in test_files[:FRAMEWORK_DETECTION_SAMPLE]: - try: - content = read_text(fpath) - - if "import pytest" in content or "from pytest" in content: - return "pytest" - - if "import unittest" in content: - return "unittest" - except Exception: - continue - - return "pytest" - - -def _detect_ts_framework(repo: Path) -> tuple[str, str]: - """Return (framework, run_command).""" - pkg = repo / "package.json" - - if pkg.exists(): - try: - data = json.loads(read_text(pkg)) - except Exception: - data = {} - - scripts = data.get("scripts", {}) - test_cmd = scripts.get("test", "") - dev_deps = data.get("devDependencies", {}) - - name = _ts_framework_from_deps(test_cmd, dev_deps) - - if name: - return name, test_cmd or name - - return "jest", "jest" - - -def _extract_run_command(repo: Path, framework: str) -> str: - """Check Makefile for test targets first, fall back to framework default.""" - for makefile in MAKEFILE_NAMES: - mk = repo / makefile - - if not mk.exists(): - continue - - content = read_text(mk) - m = re.search(r"^(?:test|test-all|tests)\s*:.*\n\t+(.+)", content, re.MULTILINE) - - if m: - return m.group(1).strip() - - return FRAMEWORK_RUN_DEFAULTS.get(framework, framework) - - -def _map_ts_tests(source_files: list[Path], test_files: list[Path], repo: Path) -> dict: - """Map TypeScript/JavaScript test files to their likely source files.""" - mapped: list[dict] = [] - untested: list[str] = [] - unmatched_tests: list[str] = [] - - source_by_stem: dict[str, Path] = {} - for sf in source_files: - stem = sf.stem.lower() - source_by_stem[stem] = sf - if stem == "index": - source_by_stem[sf.parent.name.lower()] = sf - - matched_tests: set[str] = set() - - for tf in test_files: - stem = tf.stem.lower() - candidate = re.sub(r"[.-](test|spec)$", "", stem) - candidate = re.sub(r"^(test|spec)[.-]", "", candidate) - - match = source_by_stem.get(candidate) - - if match: - matched_tests.add(str(match.relative_to(repo))) - mapped.append( - { - "source": str(match.relative_to(repo)), - "test": str(tf.relative_to(repo)), - } - ) - else: - unmatched_tests.append(str(tf.relative_to(repo))) - - for sf in source_files: - rel = str(sf.relative_to(repo)) - if rel not in matched_tests: - untested.append(rel) - - return { - "mapped": mapped, - "untested_source_files": untested, - "test_files_without_source_match": unmatched_tests, - } - - -def _map_python_tests( - source_files: list[Path], test_files: list[Path], repo: Path -) -> dict: - mapped: list[dict] = [] - untested: list[str] = [] - unmatched_tests: list[str] = [] - - source_by_stem: dict[str, Path] = {sf.stem.lower(): sf for sf in source_files} - matched_tests: set[str] = set() - - for tf in test_files: - stem = tf.stem.lower() - candidate = re.sub(r"^test_|_test$", "", stem) - match = source_by_stem.get(candidate) or source_by_stem.get(stem) - - if match: - matched_tests.add(str(match.relative_to(repo))) - - mapped.append( - { - "source": str(match.relative_to(repo)), - "test": str(tf.relative_to(repo)), - } - ) - else: - unmatched_tests.append(str(tf.relative_to(repo))) - - for sf in source_files: - rel_str = str(sf.relative_to(repo)) - - if rel_str not in matched_tests: - untested.append(rel_str) - - return { - "mapped": mapped, - "untested_source_files": untested, - "test_files_without_source_match": unmatched_tests, - } - - -def _detect_test_structure(repo: Path, test_files: list[Path]) -> dict: - if not test_files: - return {"location": "unknown", "test_dir": None, "mirrors_source": False} - - test_dirs = Counter(str(f.parent.relative_to(repo)) for f in test_files) - top_test_dirs = [d for d in test_dirs if d.split(os.sep)[0] in TOP_LEVEL_TEST_DIRS] - - if not top_test_dirs: - return {"location": "colocated", "test_dir": None, "mirrors_source": False} - - most_common_dir = Counter(d.split(os.sep)[0] for d in test_dirs).most_common(1)[0][ - 0 - ] - - test_dir = most_common_dir + "/" - - src_root = next( - (c for c in TEST_STRUCTURE_SOURCE_ROOTS if (repo / c).exists()), - None, - ) - - mirrors = ( - _mirrors_source_tree(test_dirs, most_common_dir, repo, src_root) - if src_root - else False - ) - - return { - "location": "separate_dirs", - "test_dir": test_dir, - "mirrors_source": mirrors, - } - - -def _detect_naming_patterns(test_files: list[Path]) -> dict: - func_patterns: list[str] = [] - class_patterns: list[str] = [] - file_patterns: list[str] = [] - - for fpath in test_files[:NAMING_DETECTION_SAMPLE]: - stem = fpath.stem - - if stem.startswith("test_"): - file_patterns.append("test_.py") - elif stem.endswith("_test"): - file_patterns.append("_test.py") - elif ".test." in fpath.name: - file_patterns.append(".test.ts") - elif ".spec." in fpath.name: - file_patterns.append(".spec.ts") - - try: - source = read_text(fpath) - except Exception: - continue - - if re.search(r"def (test_\w+)\s*\(", source): - func_patterns.append("test_") - - if re.search(r"(?:it|test)\s*\(\s*['\"]([^'\"]+)['\"]", source): - func_patterns.append("it('')") - - if re.search(r"class (Test\w+)", source): - class_patterns.append("Test") - - if re.search(r"class (\w+Test)", source): - class_patterns.append("Test") - - if re.search(r"describe\s*\(\s*['\"]([^'\"]+)['\"]", source): - class_patterns.append("describe('')") - - return { - "file_pattern": _most_common(file_patterns), - "function_pattern": _most_common(func_patterns), - "class_pattern": _most_common(class_patterns), - } - - -def _find_conftest_files(repo: Path) -> list[str]: - """Walk repo and return repo-relative paths of all conftest.py files.""" - found = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - if fn == "conftest.py": - found.append(str((Path(dirpath) / fn).relative_to(repo))) - - return found - - -def _extract_fixtures_from_conftest(repo: Path, conftest_paths: list[str]) -> list[str]: - """Parse each conftest.py and return the names of all fixture functions.""" - fixture_names: list[str] = [] - - for conftest_path in conftest_paths: - try: - source = read_text(repo / conftest_path) - tree = ast.parse(source) - - for node in ast.walk(tree): - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and any( - _is_fixture_decorator(d) for d in node.decorator_list - ): - fixture_names.append(node.name) - except Exception: - continue - - return fixture_names - - -def _detect_python_fixtures(repo: Path, test_files: list[Path]) -> dict: - conftest_files = _find_conftest_files(repo) - fixture_names = _extract_fixtures_from_conftest(repo, conftest_files) - - return { - "uses_conftest": bool(conftest_files), - "conftest_locations": conftest_files, - "fixture_names": fixture_names[:MAX_FIXTURE_NAMES], - } - - -def _pick_representative(test_files: list[Path]) -> str | None: - if not test_files: - return None - - sizes = sorted([(f, _count_lines(f)) for f in test_files], key=lambda x: x[1]) - return str(sizes[len(sizes) // 2][0]) - - -def _analyze_python(repo: Path) -> dict | None: - test_files, source_files = _collect_python_files(repo) - - if not test_files and not source_files: - return None - - framework = _detect_python_framework(repo, test_files) - run_cmd = _extract_run_command(repo, framework) - coverage = _map_python_tests(source_files, test_files, repo) - structure = _detect_test_structure(repo, test_files) - naming = _detect_naming_patterns(test_files) - fixtures = _detect_python_fixtures(repo, test_files) - rep_test = _pick_representative(test_files) - - return { - "framework": framework, - "run_command": run_cmd, - "test_files": len(test_files), - "source_files": len(source_files), - "coverage_shape": coverage, - "structure": structure, - "naming": naming, - "fixtures": fixtures, - "representative_test": str(Path(rep_test).relative_to(repo)) - if rep_test - else None, - } - - -def _analyze_typescript(repo: Path) -> dict | None: - test_files, source_files = _collect_ts_files(repo) - - if not test_files and not source_files: - return None - - framework, run_cmd = _detect_ts_framework(repo) - run_cmd = _extract_run_command(repo, framework) or run_cmd - coverage = _map_ts_tests(source_files, test_files, repo) - structure = _detect_test_structure(repo, test_files) - naming = _detect_naming_patterns(test_files) - rep_test = _pick_representative(test_files) - - return { - "framework": framework, - "run_command": run_cmd, - "test_files": len(test_files), - "source_files": len(source_files), - "coverage_shape": coverage, - "structure": structure, - "naming": naming, - "representative_test": str(Path(rep_test).relative_to(repo)) - if rep_test - else None, - } - - -def _collect_go_files(repo: Path) -> tuple[list[Path], list[Path]]: - test_files: list[Path] = [] - source_files: list[Path] = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - if not _has_language_suffix(fn, "go"): - continue - - fpath = Path(dirpath) / fn - - if fn.endswith("_test.go"): - test_files.append(fpath) - else: - source_files.append(fpath) - - return test_files, source_files - - -def _collect_rust_files(repo: Path) -> tuple[list[Path], list[Path]]: - test_files: list[Path] = [] - source_files: list[Path] = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - if not _has_language_suffix(fn, "rust"): - continue - - fpath = Path(dirpath) / fn - rel = str(fpath.relative_to(repo)) - - if rel.startswith("tests/") or fn.endswith("_test.rs"): - test_files.append(fpath) - else: - source_files.append(fpath) - - return test_files, source_files - - -def _collect_jvm_files( - repo: Path, language_id: str, test_roots: tuple[str, ...] -) -> tuple[list[Path], list[Path]]: - test_files: list[Path] = [] - source_files: list[Path] = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - fpath = Path(dirpath) / fn - spec = language_for_path(fpath) - - if not spec or spec.id != language_id: - continue - - rel = str(fpath.relative_to(repo)) - - if is_test_path(rel, language_id=language_id) or any( - rel.startswith(root + "/") for root in test_roots - ): - test_files.append(fpath) - else: - source_files.append(fpath) - - return test_files, source_files - - -def _collect_csharp_files(repo: Path) -> tuple[list[Path], list[Path]]: - test_files: list[Path] = [] - source_files: list[Path] = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - if not _has_language_suffix(fn, "csharp"): - continue - - fpath = Path(dirpath) / fn - rel = str(fpath.relative_to(repo)) - parts = {part.lower() for part in fpath.parent.parts} - - if ( - is_test_path(rel, language_id="csharp") - or "tests" in parts - or "test" in parts - or any(part.endswith(".tests") for part in parts) - ): - test_files.append(fpath) - else: - source_files.append(fpath) - - return test_files, source_files - - -def _collect_c_family_files( - repo: Path, language_id: str -) -> tuple[list[Path], list[Path]]: - test_files: list[Path] = [] - source_files: list[Path] = [] - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - fpath = Path(dirpath) / fn - spec = language_for_path(fpath) - - if not spec or spec.id != language_id: - continue - - if fpath.suffix.lower() not in C_FAMILY_SOURCE_EXTENSIONS[language_id]: - continue - - rel = str(fpath.relative_to(repo)) - parts = {part.lower() for part in fpath.parent.parts} - - if ( - is_test_path(rel, language_id=language_id) - or "tests" in parts - or "test" in parts - ): - test_files.append(fpath) - else: - source_files.append(fpath) - - return test_files, source_files - - -def _collect_ruby_files(repo: Path) -> tuple[list[Path], list[Path]]: - test_files: list[Path] = [] - source_files: list[Path] = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - if not _has_language_suffix(fn, "ruby"): - continue - - fpath = Path(dirpath) / fn - rel = str(fpath.relative_to(repo)) - - if ( - is_test_path(rel, language_id="ruby") - or rel.startswith("spec/") - or rel.startswith("test/") - ): - test_files.append(fpath) - else: - source_files.append(fpath) - - return test_files, source_files - - -def _collect_php_files(repo: Path) -> tuple[list[Path], list[Path]]: - test_files: list[Path] = [] - source_files: list[Path] = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - if not _has_language_suffix(fn, "php"): - continue - - fpath = Path(dirpath) / fn - rel = str(fpath.relative_to(repo)) - - if is_test_path(rel, language_id="php") or rel.startswith("tests/"): - test_files.append(fpath) - else: - source_files.append(fpath) - - return test_files, source_files - - -def _collect_bash_files(repo: Path) -> tuple[list[Path], list[Path]]: - test_files: list[Path] = [] - source_files: list[Path] = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - fpath = Path(dirpath) / fn - if fpath.suffix.lower() == ".bats": - test_files.append(fpath) - continue - - spec = language_for_path(fpath) - - if not spec or spec.id != "bash": - continue - - rel = str(fpath.relative_to(repo)) - is_bats = fpath.suffix.lower() == ".bats" - - if ( - is_bats - or is_test_path(rel, language_id="bash") - or rel.startswith(("test/", "tests/")) - ): - test_files.append(fpath) - else: - source_files.append(fpath) - - return test_files, source_files - - -def _is_objectivec_header(path: Path) -> bool: - if path.suffix.lower() != ".h": - return False - - content = read_text(path) - - return any( - marker in content - for marker in ("@interface", "@protocol", "@implementation", "#import") - ) - - -def _collect_swift_files(repo: Path) -> tuple[list[Path], list[Path]]: - test_files: list[Path] = [] - source_files: list[Path] = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - if not _has_language_suffix(fn, "swift"): - continue - - fpath = Path(dirpath) / fn - rel = str(fpath.relative_to(repo)) - - if is_test_path(rel, language_id="swift") or rel.startswith("Tests/"): - test_files.append(fpath) - else: - source_files.append(fpath) - - return test_files, source_files - - -def _collect_objectivec_files(repo: Path) -> tuple[list[Path], list[Path]]: - test_files: list[Path] = [] - source_files: list[Path] = [] - - for dirpath, dirs, files in os.walk(repo): - dirs[:] = [d for d in dirs if not should_skip_dir(d)] - - for fn in files: - fpath = Path(dirpath) / fn - suffix = fpath.suffix.lower() - - if suffix not in OBJECTIVEC_SOURCE_EXTENSIONS and not ( - suffix == ".h" and _is_objectivec_header(fpath) - ): - continue - - rel = str(fpath.relative_to(repo)) - - if rel.startswith("Tests/") or fn.endswith(("Tests.m", "Tests.mm")): - test_files.append(fpath) - else: - source_files.append(fpath) - - return test_files, source_files - - -def _detect_go_framework(repo: Path) -> str: - return "go test" - - -def _detect_rust_framework(repo: Path) -> str: - return "cargo test" - - -def _detect_jvm_framework(test_files: list[Path]) -> str: - for fpath in test_files[:FRAMEWORK_DETECTION_SAMPLE]: - try: - content = read_text(fpath) - except Exception: - continue - - for marker, framework in JVM_TEST_FRAMEWORKS.items(): - if marker in content: - return framework - - if "@Test" in content or "@ParameterizedTest" in content: - return "junit" - - return "junit" - - -def _detect_csharp_framework(test_files: list[Path]) -> str: - for fpath in test_files[:FRAMEWORK_DETECTION_SAMPLE]: - try: - content = read_text(fpath) - except Exception: - continue - - for marker, framework in CSHARP_TEST_FRAMEWORKS.items(): - if marker in content: - return framework - - if "[Test]" in content: - return "nunit" - - return "xunit" - - -def _detect_c_cpp_framework(test_files: list[Path]) -> str: - for fpath in test_files[:FRAMEWORK_DETECTION_SAMPLE]: - try: - content = read_text(fpath) - except Exception: - continue - - for marker, framework in C_CPP_TEST_FRAMEWORKS.items(): - if marker in content: - return framework - - return "ctest" - - -def _detect_ruby_framework(test_files: list[Path]) -> str: - for fpath in test_files: - rel = fpath.as_posix() - - if "/spec/" in rel or fpath.name.endswith("_spec.rb"): - return "rspec" - - for fpath in test_files[:FRAMEWORK_DETECTION_SAMPLE]: - try: - content = read_text(fpath) - except Exception: - continue - - for marker, framework in RUBY_TEST_FRAMEWORKS.items(): - if marker in content: - return framework - - rel = str(fpath) - if "/test/" in rel or fpath.name.startswith("test_"): - return "minitest" - - return "rspec" - - -def _detect_php_framework(repo: Path, test_files: list[Path]) -> str: - composer = repo / "composer.json" - - if composer.exists(): - try: - data = json.loads(read_text(composer)) - except Exception: - data = {} - - if "phpunit/phpunit" in data.get("require-dev", {}): - return "phpunit" - - for fpath in test_files[:FRAMEWORK_DETECTION_SAMPLE]: - try: - content = read_text(fpath) - except Exception: - continue - - for marker, framework in PHP_TEST_FRAMEWORKS.items(): - if marker in content: - return framework - - return "phpunit" - - -def _detect_bash_framework(test_files: list[Path]) -> str: - for fpath in test_files[:FRAMEWORK_DETECTION_SAMPLE]: - if fpath.suffix.lower() == ".bats": - return "bats" - - try: - content = read_text(fpath) - except Exception: - continue - - for marker, framework in SHELL_TEST_FRAMEWORKS.items(): - if marker in content: - return framework - - return "bash" - - -def _detect_swift_framework(test_files: list[Path]) -> str: - for fpath in test_files[:FRAMEWORK_DETECTION_SAMPLE]: - try: - content = read_text(fpath) - except Exception: - continue - - for marker, framework in SWIFT_TEST_FRAMEWORKS.items(): - if marker in content: - return framework - - return "xctest" - - -def _detect_objectivec_framework(test_files: list[Path]) -> str: - for fpath in test_files[:FRAMEWORK_DETECTION_SAMPLE]: - try: - content = read_text(fpath) - except Exception: - continue - - for marker, framework in OBJC_TEST_FRAMEWORKS.items(): - if marker in content: - return framework - - return "xctest" - - -def _map_go_tests(source_files: list[Path], test_files: list[Path], repo: Path) -> dict: - mapped: list[dict] = [] - untested: list[str] = [] - unmatched_tests: list[str] = [] - - source_by_stem: dict[str, Path] = {} - for sf in source_files: - stem = sf.stem - source_by_stem[stem] = sf - - matched_tests: set[str] = set() - - for tf in test_files: - stem = tf.stem - candidate = stem[:-5] if stem.endswith("_test") else stem - - match = source_by_stem.get(candidate) - - if match: - matched_tests.add(str(match.relative_to(repo))) - mapped.append( - { - "source": str(match.relative_to(repo)), - "test": str(tf.relative_to(repo)), - } - ) - else: - unmatched_tests.append(str(tf.relative_to(repo))) - - for sf in source_files: - rel = str(sf.relative_to(repo)) - if rel not in matched_tests: - untested.append(rel) - - return { - "mapped": mapped, - "untested_source_files": untested, - "test_files_without_source_match": unmatched_tests, - } - - -def _map_rust_tests( - source_files: list[Path], test_files: list[Path], repo: Path -) -> dict: - mapped: list[dict] = [] - untested: list[str] = [] - unmatched_tests: list[str] = [] - - source_by_stem: dict[str, Path] = {} - for sf in source_files: - stem = sf.stem - source_by_stem[stem] = sf - - matched_tests: set[str] = set() - - for tf in test_files: - stem = tf.stem - candidate = stem[:-5] if stem.endswith("_test") else stem - - match = source_by_stem.get(candidate) - - if match: - matched_tests.add(str(match.relative_to(repo))) - mapped.append( - { - "source": str(match.relative_to(repo)), - "test": str(tf.relative_to(repo)), - } - ) - else: - unmatched_tests.append(str(tf.relative_to(repo))) - - for sf in source_files: - rel = str(sf.relative_to(repo)) - if rel not in matched_tests: - untested.append(rel) - - return { - "mapped": mapped, - "untested_source_files": untested, - "test_files_without_source_match": unmatched_tests, - } - - -def _map_jvm_tests( - source_files: list[Path], test_files: list[Path], repo: Path -) -> dict: - mapped: list[dict] = [] - untested: list[str] = [] - unmatched_tests: list[str] = [] - source_by_key: dict[tuple[str, str], Path] = {} - matched_tests: set[str] = set() - - for sf in source_files: - rel = sf.relative_to(repo) - stem = sf.stem.lower() - parent_key = rel.parent.as_posix().lower() - source_by_key[(parent_key, stem)] = sf - - for tf in test_files: - rel = tf.relative_to(repo) - stem = tf.stem - candidate = re.sub(r"(tests?|spec)$", "", stem, flags=re.IGNORECASE).lower() - parent_parts = list(rel.parent.parts) - - if "test" in parent_parts: - parent_parts[parent_parts.index("test")] = "main" - if "java" in parent_parts and "test" not in parent_parts: - parent_parts = parent_parts - if "kotlin" in parent_parts and "test" not in parent_parts: - parent_parts = parent_parts - - parent_key = Path(*parent_parts).as_posix().lower() - match = source_by_key.get((parent_key, candidate)) - - if match is None and "src/test/" in rel.as_posix(): - alt_parent = rel.parent.as_posix().replace("src/test/", "src/main/", 1) - match = source_by_key.get((alt_parent.lower(), candidate)) - - if match is None: - package_tail = "/".join(rel.parent.parts[-3:]).lower() - for (key_parent, key_stem), source_file in source_by_key.items(): - if key_stem == candidate and ( - key_parent.endswith(package_tail) - or package_tail.endswith(key_parent) - ): - match = source_file - break - - if match: - matched_tests.add(str(match.relative_to(repo))) - - mapped.append( - { - "source": str(match.relative_to(repo)), - "test": str(tf.relative_to(repo)), - } - ) - else: - unmatched_tests.append(str(tf.relative_to(repo))) - - for sf in source_files: - rel_str = str(sf.relative_to(repo)) - - if rel_str not in matched_tests: - untested.append(rel_str) - - return { - "mapped": mapped, - "untested_source_files": untested, - "test_files_without_source_match": unmatched_tests, - } - - -def _map_stem_tests( - source_files: list[Path], test_files: list[Path], repo: Path -) -> dict: - mapped: list[dict] = [] - untested: list[str] = [] - unmatched_tests: list[str] = [] - source_by_stem: dict[str, Path] = {} - matched_tests: set[str] = set() - - for sf in source_files: - source_by_stem[sf.stem.lower()] = sf - - for tf in test_files: - stem = tf.stem.lower() - candidate = re.sub(r"([_.-](test|tests|spec))$", "", stem) - candidate = re.sub(r"(tests?|spec)$", "", candidate) - candidate = re.sub(r"^(test[_.-]?)", "", candidate) - candidate = re.sub(r"([_.-](test|tests|spec))$", "", candidate) - match = source_by_stem.get(candidate) - - if match: - matched_tests.add(str(match.relative_to(repo))) - - mapped.append( - { - "source": str(match.relative_to(repo)), - "test": str(tf.relative_to(repo)), - } - ) - else: - unmatched_tests.append(str(tf.relative_to(repo))) - - for sf in source_files: - rel = str(sf.relative_to(repo)) - - if rel not in matched_tests: - untested.append(rel) - - return { - "mapped": mapped, - "untested_source_files": untested, - "test_files_without_source_match": unmatched_tests, - } - - -def _analyze_go(repo: Path) -> dict | None: - test_files, source_files = _collect_go_files(repo) - - if not test_files and not source_files: - return None - - framework = _detect_go_framework(repo) - run_cmd = _extract_run_command(repo, framework) or framework - coverage = _map_go_tests(source_files, test_files, repo) - structure = _detect_test_structure(repo, test_files) - naming = _detect_naming_patterns(test_files) - rep_test = _pick_representative(test_files) - - return { - "framework": framework, - "run_command": run_cmd, - "test_files": len(test_files), - "source_files": len(source_files), - "coverage_shape": coverage, - "structure": structure, - "naming": naming, - "representative_test": str(Path(rep_test).relative_to(repo)) - if rep_test - else None, - } - - -def _analyze_rust(repo: Path) -> dict | None: - test_files, source_files = _collect_rust_files(repo) - - if not test_files and not source_files: - return None - - framework = _detect_rust_framework(repo) - run_cmd = _extract_run_command(repo, framework) or framework - coverage = _map_rust_tests(source_files, test_files, repo) - structure = _detect_test_structure(repo, test_files) - naming = _detect_naming_patterns(test_files) - rep_test = _pick_representative(test_files) - - return { - "framework": framework, - "run_command": run_cmd, - "test_files": len(test_files), - "source_files": len(source_files), - "coverage_shape": coverage, - "structure": structure, - "naming": naming, - "representative_test": str(Path(rep_test).relative_to(repo)) - if rep_test - else None, - } - - -def _analyze_java(repo: Path) -> dict | None: - test_files, source_files = _collect_jvm_files(repo, "java", ("src/test/java",)) - - if not test_files and not source_files: - return None - - framework = _detect_jvm_framework(test_files) - run_cmd = _extract_run_command(repo, "junit") or "junit" - coverage = _map_jvm_tests(source_files, test_files, repo) - structure = _detect_test_structure(repo, test_files) - naming = _detect_naming_patterns(test_files) - rep_test = _pick_representative(test_files) - - return { - "framework": framework, - "run_command": run_cmd, - "test_files": len(test_files), - "source_files": len(source_files), - "coverage_shape": coverage, - "structure": structure, - "naming": naming, - "representative_test": str(Path(rep_test).relative_to(repo)) - if rep_test - else None, - } - - -def _analyze_kotlin(repo: Path) -> dict | None: - test_files, source_files = _collect_jvm_files(repo, "kotlin", ("src/test/kotlin",)) - - if not test_files and not source_files: - return None - - framework = _detect_jvm_framework(test_files) - run_cmd = _extract_run_command(repo, framework) or framework - coverage = _map_jvm_tests(source_files, test_files, repo) - structure = _detect_test_structure(repo, test_files) - naming = _detect_naming_patterns(test_files) - rep_test = _pick_representative(test_files) - - return { - "framework": framework, - "run_command": run_cmd, - "test_files": len(test_files), - "source_files": len(source_files), - "coverage_shape": coverage, - "structure": structure, - "naming": naming, - "representative_test": str(Path(rep_test).relative_to(repo)) - if rep_test - else None, - } - - -def _analyze_csharp(repo: Path) -> dict | None: - test_files, source_files = _collect_csharp_files(repo) - - if not test_files and not source_files: - return None - - framework = _detect_csharp_framework(test_files) - run_cmd = _extract_run_command(repo, framework) or framework - coverage = _map_stem_tests(source_files, test_files, repo) - structure = _detect_test_structure(repo, test_files) - naming = _detect_naming_patterns(test_files) - rep_test = _pick_representative(test_files) - - return { - "framework": framework, - "run_command": run_cmd, - "test_files": len(test_files), - "source_files": len(source_files), - "coverage_shape": coverage, - "structure": structure, - "naming": naming, - "representative_test": str(Path(rep_test).relative_to(repo)) - if rep_test - else None, - } - - -def _analyze_c(repo: Path) -> dict | None: - test_files, source_files = _collect_c_family_files(repo, "c") - - if not test_files and not source_files: - return None - - framework = _detect_c_cpp_framework(test_files) - run_cmd = _extract_run_command(repo, framework) or framework - coverage = _map_stem_tests(source_files, test_files, repo) - structure = _detect_test_structure(repo, test_files) - naming = _detect_naming_patterns(test_files) - rep_test = _pick_representative(test_files) - - return { - "framework": framework, - "run_command": run_cmd, - "test_files": len(test_files), - "source_files": len(source_files), - "coverage_shape": coverage, - "structure": structure, - "naming": naming, - "representative_test": str(Path(rep_test).relative_to(repo)) - if rep_test - else None, - } - - -def _analyze_cpp(repo: Path) -> dict | None: - test_files, source_files = _collect_c_family_files(repo, "cpp") - - if not test_files and not source_files: - return None - - framework = _detect_c_cpp_framework(test_files) - run_cmd = _extract_run_command(repo, framework) or framework - coverage = _map_stem_tests(source_files, test_files, repo) - structure = _detect_test_structure(repo, test_files) - naming = _detect_naming_patterns(test_files) - rep_test = _pick_representative(test_files) - - return { - "framework": framework, - "run_command": run_cmd, - "test_files": len(test_files), - "source_files": len(source_files), - "coverage_shape": coverage, - "structure": structure, - "naming": naming, - "representative_test": str(Path(rep_test).relative_to(repo)) - if rep_test - else None, - } - - -def _analyze_ruby(repo: Path) -> dict | None: - test_files, source_files = _collect_ruby_files(repo) - - if not test_files and not source_files: - return None - - framework = _detect_ruby_framework(test_files) - run_cmd = _extract_run_command(repo, framework) or framework - coverage = _map_stem_tests(source_files, test_files, repo) - structure = _detect_test_structure(repo, test_files) - naming = _detect_naming_patterns(test_files) - rep_test = _pick_representative(test_files) - - return { - "framework": framework, - "run_command": run_cmd, - "test_files": len(test_files), - "source_files": len(source_files), - "coverage_shape": coverage, - "structure": structure, - "naming": naming, - "representative_test": str(Path(rep_test).relative_to(repo)) - if rep_test - else None, - } - - -def _analyze_php(repo: Path) -> dict | None: - test_files, source_files = _collect_php_files(repo) - - if not test_files and not source_files: - return None - - framework = _detect_php_framework(repo, test_files) - run_cmd = _extract_run_command(repo, framework) or framework - coverage = _map_stem_tests(source_files, test_files, repo) - structure = _detect_test_structure(repo, test_files) - naming = _detect_naming_patterns(test_files) - rep_test = _pick_representative(test_files) - - return { - "framework": framework, - "run_command": run_cmd, - "test_files": len(test_files), - "source_files": len(source_files), - "coverage_shape": coverage, - "structure": structure, - "naming": naming, - "representative_test": str(Path(rep_test).relative_to(repo)) - if rep_test - else None, - } - - -def _analyze_bash(repo: Path) -> dict | None: - test_files, source_files = _collect_bash_files(repo) - - if not test_files and not source_files: - return None - - framework = _detect_bash_framework(test_files) - run_cmd = _extract_run_command(repo, framework) or framework - coverage = _map_stem_tests(source_files, test_files, repo) - structure = _detect_test_structure(repo, test_files) - naming = _detect_naming_patterns(test_files) - rep_test = _pick_representative(test_files) - - return { - "framework": framework, - "run_command": run_cmd, - "test_files": len(test_files), - "source_files": len(source_files), - "coverage_shape": coverage, - "structure": structure, - "naming": naming, - "representative_test": str(Path(rep_test).relative_to(repo)) - if rep_test - else None, - } - - -def _analyze_swift(repo: Path) -> dict | None: - test_files, source_files = _collect_swift_files(repo) - - if not test_files and not source_files: - return None - - framework = _detect_swift_framework(test_files) - run_cmd = _extract_run_command(repo, framework) or framework - coverage = _map_stem_tests(source_files, test_files, repo) - structure = _detect_test_structure(repo, test_files) - naming = _detect_naming_patterns(test_files) - rep_test = _pick_representative(test_files) - - return { - "framework": framework, - "run_command": run_cmd, - "test_files": len(test_files), - "source_files": len(source_files), - "coverage_shape": coverage, - "structure": structure, - "naming": naming, - "representative_test": str(Path(rep_test).relative_to(repo)) - if rep_test - else None, - } - - -def _analyze_objectivec(repo: Path) -> dict | None: - test_files, source_files = _collect_objectivec_files(repo) - - if not test_files and not source_files: - return None - - framework = _detect_objectivec_framework(test_files) - run_cmd = _extract_run_command(repo, framework) or framework - - coverage = _map_stem_tests( - [f for f in source_files if f.suffix.lower() in {".m", ".mm"}], - [f for f in test_files if f.suffix.lower() in {".m", ".mm"}], - repo, - ) - - structure = _detect_test_structure(repo, test_files) - naming = _detect_naming_patterns(test_files) - rep_test = _pick_representative(test_files) - - return { - "framework": framework, - "run_command": run_cmd, - "test_files": len(test_files), - "source_files": len(source_files), - "coverage_shape": coverage, - "structure": structure, - "naming": naming, - "representative_test": str(Path(rep_test).relative_to(repo)) - if rep_test - else None, - } - - -def analyze_tests(repo_path: str) -> dict: - try: - repo = validate_repo(repo_path) - except ValueError as exc: - return {"error": str(exc), "script": "tests"} - - result: dict = {} - - try: - py = _analyze_python(repo) - - if py: - result["python"] = py - except Exception as exc: - result["python"] = {"error": str(exc)} - - try: - ts = _analyze_typescript(repo) - - if ts: - result["typescript"] = ts - except Exception as exc: - result["typescript"] = {"error": str(exc)} - - try: - go = _analyze_go(repo) - - if go: - result["go"] = go - except Exception as exc: - result["go"] = {"error": str(exc)} - - try: - rs = _analyze_rust(repo) - - if rs: - result["rust"] = rs - except Exception as exc: - result["rust"] = {"error": str(exc)} - - try: - java = _analyze_java(repo) - - if java: - result["java"] = java - except Exception as exc: - result["java"] = {"error": str(exc)} - - try: - kotlin = _analyze_kotlin(repo) - - if kotlin: - result["kotlin"] = kotlin - except Exception as exc: - result["kotlin"] = {"error": str(exc)} - - try: - csharp = _analyze_csharp(repo) - - if csharp: - result["csharp"] = csharp - except Exception as exc: - result["csharp"] = {"error": str(exc)} - - try: - c_lang = _analyze_c(repo) - - if c_lang: - result["c"] = c_lang - except Exception as exc: - result["c"] = {"error": str(exc)} - - try: - cpp = _analyze_cpp(repo) - - if cpp: - result["cpp"] = cpp - except Exception as exc: - result["cpp"] = {"error": str(exc)} - - try: - ruby = _analyze_ruby(repo) - - if ruby: - result["ruby"] = ruby - except Exception as exc: - result["ruby"] = {"error": str(exc)} - - try: - php = _analyze_php(repo) - - if php: - result["php"] = php - except Exception as exc: - result["php"] = {"error": str(exc)} - - try: - bash = _analyze_bash(repo) - - if bash: - result["bash"] = bash - except Exception as exc: - result["bash"] = {"error": str(exc)} - - try: - swift = _analyze_swift(repo) - - if swift: - result["swift"] = swift - except Exception as exc: - result["swift"] = {"error": str(exc)} - - try: - objectivec = _analyze_objectivec(repo) - - if objectivec: - result["objectivec"] = objectivec - except Exception as exc: - result["objectivec"] = {"error": str(exc)} - - return result - - -def main(argv: list[str] | None = None) -> int: - return run_command_main( - argv=argv, - description=__doc__, - command_fn=analyze_tests, - script_name="tests", - ) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/agentskill/common/__init__.py b/agentskill/common/__init__.py deleted file mode 100644 index 79e8e8a..0000000 --- a/agentskill/common/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Small shared helpers reused by multiple commands.""" diff --git a/agentskill/common/constants.py b/agentskill/common/constants.py deleted file mode 100644 index 9d6a522..0000000 --- a/agentskill/common/constants.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Shared constants for repository walking and test discovery.""" - -MAX_FILES_TO_PARSE = 10_000 -MAX_FILE_BYTES = 1_000_000 - -MAKEFILE_NAMES = ("Makefile", "makefile", "GNUmakefile") -TOP_LEVEL_TEST_DIRS = {"tests", "test", "__tests__", "spec"} -TEST_STRUCTURE_SOURCE_ROOTS = ("src", "lib", "pkg") - -SKIP_DIRS: set[str] = { - "node_modules", - "__pycache__", - "dist", - "build", - "out", - "target", - "vendor", - "third_party", - ".eggs", - "site-packages", - "venv", - ".venv", - ".tox", - ".nox", - ".pytest_cache", - ".mypy_cache", - ".ruff_cache", - "htmlcov", - ".next", - ".nuxt", - "coverage", -} - - -def should_skip_dir(name: str) -> bool: - return name in SKIP_DIRS or name.startswith(".") diff --git a/agentskill/common/fs.py b/agentskill/common/fs.py deleted file mode 100644 index 43c0de5..0000000 --- a/agentskill/common/fs.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Small filesystem helpers shared by analyzer commands.""" - -from pathlib import Path - -from agentskill.common.constants import MAX_FILE_BYTES - - -def validate_repo(path: str) -> Path: - repo = Path(path).resolve() - - if not repo.exists(): - raise ValueError(f"path does not exist: {path}") - - if not repo.is_dir(): - raise ValueError(f"not a directory: {path}") - - return repo - - -def count_lines(path: Path) -> int: - try: - with open(path, "rb") as file_obj: - return sum( - chunk.count(b"\n") for chunk in iter(lambda: file_obj.read(65_536), b"") - ) - except Exception: - return 0 - - -def read_text(path: Path, max_bytes: int | None = MAX_FILE_BYTES) -> str: - try: - with open(path, "rb") as file_obj: - raw = file_obj.read() if max_bytes is None else file_obj.read(max_bytes) - except Exception: - return "" - - return raw.decode(errors="ignore") diff --git a/agentskill/common/languages.py b/agentskill/common/languages.py deleted file mode 100644 index 6fd2925..0000000 --- a/agentskill/common/languages.py +++ /dev/null @@ -1,341 +0,0 @@ -"""Central language registry and detection helpers for all analyzers.""" - -from dataclasses import dataclass -from fnmatch import fnmatch -from pathlib import Path - -SHELL_SHEBANG_MARKERS = ("bash", "sh") - - -@dataclass(frozen=True) -class LanguageSpec: - id: str - display_name: str - extensions: tuple[str, ...] - config_files: tuple[str, ...] = () - package_files: tuple[str, ...] = () - test_patterns: tuple[str, ...] = () - source_roots: tuple[str, ...] = () - - -_PYTHON = LanguageSpec( - id="python", - display_name="Python", - extensions=(".py",), - config_files=( - "pyproject.toml", - "setup.cfg", - ".flake8", - ".isort.cfg", - "ruff.toml", - ".ruff.toml", - ), - package_files=("pyproject.toml", "setup.py", "setup.cfg", "requirements.txt"), - test_patterns=("test_*.py", "*_test.py", "tests/**/*.py"), - source_roots=("src",), -) - -_TYPESCRIPT = LanguageSpec( - id="typescript", - display_name="TypeScript", - extensions=(".ts", ".tsx"), - config_files=("tsconfig.json", "tsconfig.*.json"), - package_files=("package.json",), - test_patterns=( - "*.test.ts", - "*.spec.ts", - "*.test.tsx", - "*.spec.tsx", - "__tests__/**/*", - ), - source_roots=("src",), -) - -_JAVASCRIPT = LanguageSpec( - id="javascript", - display_name="JavaScript", - extensions=(".js", ".jsx", ".mjs", ".cjs"), - config_files=("jsconfig.json",), - package_files=("package.json",), - test_patterns=( - "*.test.js", - "*.spec.js", - "__tests__/**/*", - ), - source_roots=("src",), -) - -_GO = LanguageSpec( - id="go", - display_name="Go", - extensions=(".go",), - config_files=("go.mod",), - package_files=("go.mod", "go.work"), - test_patterns=("*_test.go",), - source_roots=(), -) - -_RUST = LanguageSpec( - id="rust", - display_name="Rust", - extensions=(".rs",), - config_files=("rustfmt.toml", ".rustfmt.toml", "clippy.toml"), - package_files=("Cargo.toml",), - test_patterns=("tests/**/*.rs", "*_test.rs"), - source_roots=("src",), -) - -_JAVA = LanguageSpec( - id="java", - display_name="Java", - extensions=(".java",), - config_files=(), - package_files=( - "pom.xml", - "build.gradle", - "build.gradle.kts", - "settings.gradle", - "settings.gradle.kts", - ), - test_patterns=("*Test.java", "*Tests.java", "src/test/java/**/*.java"), - source_roots=("src/main/java", "src/test/java"), -) - -_KOTLIN = LanguageSpec( - id="kotlin", - display_name="Kotlin", - extensions=(".kt", ".kts"), - config_files=(), - package_files=( - "pom.xml", - "build.gradle", - "build.gradle.kts", - "settings.gradle", - "settings.gradle.kts", - ), - test_patterns=("*Test.kt", "*Tests.kt", "src/test/kotlin/**/*.kt"), - source_roots=("src/main/kotlin", "src/test/kotlin"), -) - -_CSHARP = LanguageSpec( - id="csharp", - display_name="C#", - extensions=(".cs",), - config_files=(), - package_files=( - ".csproj", - ".sln", - "Directory.Build.props", - "Directory.Build.targets", - ), - test_patterns=("*Tests.cs", "*Test.cs", "tests/**/*.cs", "test/**/*.cs"), - source_roots=("src",), -) - -_C = LanguageSpec( - id="c", - display_name="C", - extensions=(".c", ".h"), - config_files=(), - package_files=("CMakeLists.txt", "Makefile", "makefile", "GNUmakefile"), - test_patterns=("*_test.c", "*_tests.c", "test_*.c", "tests/**/*.c", "test/**/*.c"), - source_roots=("src",), -) - -_CPP = LanguageSpec( - id="cpp", - display_name="C++", - extensions=(".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"), - config_files=(), - package_files=("CMakeLists.txt", "Makefile", "makefile", "GNUmakefile"), - test_patterns=( - "*_test.cpp", - "*_tests.cpp", - "test_*.cpp", - "*_test.cc", - "*_tests.cc", - "test_*.cc", - "*_test.cxx", - "*_tests.cxx", - "test_*.cxx", - "tests/**/*.cpp", - "tests/**/*.cc", - "tests/**/*.cxx", - "test/**/*.cpp", - "test/**/*.cc", - "test/**/*.cxx", - ), - source_roots=("src",), -) - -_RUBY = LanguageSpec( - id="ruby", - display_name="Ruby", - extensions=(".rb",), - config_files=(".rubocop.yml",), - package_files=("Gemfile", "Gemfile.lock", ".gemspec"), - test_patterns=("*_spec.rb", "spec/**/*.rb", "test/**/*.rb", "test_*.rb"), - source_roots=("lib", "app"), -) - -_PHP = LanguageSpec( - id="php", - display_name="PHP", - extensions=(".php",), - config_files=("phpcs.xml", ".phpcs.xml"), - package_files=("composer.json", "composer.lock"), - test_patterns=("*Test.php", "tests/**/*.php"), - source_roots=("src",), -) - -_SWIFT = LanguageSpec( - id="swift", - display_name="Swift", - extensions=(".swift",), - config_files=(), - package_files=("Package.swift", "Podfile", ".xcodeproj", ".xcworkspace"), - test_patterns=("*Tests.swift", "Tests/**/*.swift"), - source_roots=("Sources",), -) - -_OBJECTIVEC = LanguageSpec( - id="objectivec", - display_name="Objective-C", - extensions=(".m", ".mm"), - config_files=(), - package_files=("Podfile", ".xcodeproj", ".xcworkspace"), - test_patterns=("*Tests.m", "*Tests.mm"), - source_roots=(), -) - -_BASH = LanguageSpec( - id="bash", - display_name="Bash", - extensions=(".sh", ".bash"), - config_files=(), - package_files=(), - test_patterns=("test_*.sh", "*_test.sh", "*.bats", "tests/**/*.sh", "test/**/*.sh"), - source_roots=(), -) - - -_LANGUAGES: tuple[LanguageSpec, ...] = ( - _PYTHON, - _TYPESCRIPT, - _JAVASCRIPT, - _GO, - _RUST, - _JAVA, - _KOTLIN, - _CSHARP, - _C, - _CPP, - _RUBY, - _PHP, - _SWIFT, - _OBJECTIVEC, - _BASH, -) - - -def _build_registry() -> tuple[ - dict[str, LanguageSpec], - dict[str, LanguageSpec], -]: - by_id: dict[str, LanguageSpec] = {} - by_ext: dict[str, LanguageSpec] = {} - - for spec in _LANGUAGES: - by_id[spec.id] = spec - - for ext in spec.extensions: - normalized = ext.lower() - - if normalized in by_ext: - raise ValueError( - f"Duplicate extension {normalized!r} in " - f"{spec.id!r} and {by_ext[normalized].id!r}" - ) - - by_ext[normalized] = spec - - return by_id, by_ext - - -_BY_ID, _BY_EXT = _build_registry() - - -def all_language_specs() -> tuple[LanguageSpec, ...]: - return _LANGUAGES - - -def language_by_id(language_id: str) -> LanguageSpec | None: - return _BY_ID.get(language_id) - - -def language_for_extension(extension: str) -> LanguageSpec | None: - normalized = extension.lower() - - if not normalized.startswith("."): - normalized = "." + normalized - - return _BY_EXT.get(normalized) - - -def language_for_path(path: str | Path) -> LanguageSpec | None: - p = Path(path) - spec = language_for_extension(p.suffix) - - if spec is not None: - return spec - - if p.exists() and p.is_file() and has_shell_shebang(p): - return _BY_ID.get("bash") - - return None - - -def has_shell_shebang(path: str | Path) -> bool: - p = Path(path) - - try: - first_line = p.read_text(errors="ignore").splitlines()[:1] - except Exception: - return False - - if not first_line: - return False - - line = first_line[0].strip() - - if not line.startswith("#!"): - return False - - return any(marker in line for marker in SHELL_SHEBANG_MARKERS) - - -def is_supported_language(language_id: str) -> bool: - return language_id in _BY_ID - - -def is_test_path(path: str | Path, language_id: str | None = None) -> bool: - p = Path(path) - name = p.name - rel = str(p) - - specs: tuple[LanguageSpec, ...] - - if language_id is not None: - if language_id in _BY_ID: - specs = (_BY_ID[language_id],) - else: - return False - else: - specs = _LANGUAGES - - for spec in specs: - for pattern in spec.test_patterns: - if fnmatch(name, pattern) or fnmatch(rel, pattern): - return True - - return False diff --git a/agentskill/common/walk.py b/agentskill/common/walk.py deleted file mode 100644 index 9a4d04b..0000000 --- a/agentskill/common/walk.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Shared repository traversal helpers.""" - -from dataclasses import dataclass -from pathlib import Path - -from agentskill.common.constants import ( - MAX_FILE_BYTES, - MAX_FILES_TO_PARSE, - should_skip_dir, -) - - -@dataclass -class WalkStats: - files_seen: int - files_yielded: int - hit_max_files: bool - oversize_files: int - - -def walk_repo( - repo: Path, - *, - max_files: int = MAX_FILES_TO_PARSE, - max_file_bytes: int = MAX_FILE_BYTES, -) -> tuple[list[Path], WalkStats]: - paths: list[Path] = [] - files_seen = 0 - oversize_files = 0 - - def _walk(directory: Path) -> None: - nonlocal files_seen, oversize_files - - entries = sorted(directory.iterdir(), key=lambda entry: entry.name) - dirs = [entry for entry in entries if entry.is_dir() and not entry.is_symlink()] - - files = [ - entry for entry in entries if entry.is_file() and not entry.is_symlink() - ] - - for subdir in dirs: - if should_skip_dir(subdir.name): - continue - - if files_seen >= max_files: - return - - _walk(subdir) - - if files_seen >= max_files: - return - - for path in files: - if files_seen >= max_files: - return - - files_seen += 1 - - try: - size_bytes = path.stat().st_size - except Exception: - size_bytes = 0 - - if size_bytes > max_file_bytes: - oversize_files += 1 - - paths.append(path) - - _walk(repo) - - stats = WalkStats( - files_seen=files_seen, - files_yielded=len(paths), - hit_max_files=files_seen >= max_files, - oversize_files=oversize_files, - ) - - return paths, stats diff --git a/agentskill/lib/__init__.py b/agentskill/lib/__init__.py deleted file mode 100644 index b17cd0c..0000000 --- a/agentskill/lib/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Shared library helpers for CLI orchestration and output.""" diff --git a/agentskill/lib/agents_document.py b/agentskill/lib/agents_document.py deleted file mode 100644 index 637dd8f..0000000 --- a/agentskill/lib/agents_document.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Helpers for parsing and updating sectioned AGENTS.md documents.""" - -import re -from dataclasses import dataclass - -ATX_HEADING_RE = re.compile(r"^[ \t]{0,3}(?P#{1,6})(?:[ \t]+(?P.*))?$") - - -def normalize_section_name(name: str) -> str: - """Normalize a section name for deterministic lookup.""" - normalized = re.sub(r"\s+", " ", name.strip().lower()) - normalized = re.sub(r"^\d+\.\s*", "", normalized) - return normalized - - -@dataclass(frozen=True) -class AgentsSection: - """A single heading-delimited AGENTS.md section.""" - - heading_text: str - normalized_name: str - heading_level: int - body: str - - def __post_init__(self) -> None: - object.__setattr__( - self, - "normalized_name", - normalize_section_name(self.heading_text), - ) - - -@dataclass(frozen=True) -class AgentsDocument: - """A parsed AGENTS.md document with ordered sections.""" - - preamble: str - sections: list[AgentsSection] - - -def build_section( - heading_text: str, - body: str, - *, - heading_level: int = 2, -) -> AgentsSection: - """Build a section with normalized metadata.""" - return AgentsSection( - heading_text=heading_text, - normalized_name="", - heading_level=heading_level, - body=body, - ) - - -def _parse_heading(line: str) -> tuple[int, str] | None: - raw_line = line.rstrip("\r\n") - match = ATX_HEADING_RE.match(raw_line) - - if match is None: - return None - - marks = match.group("marks") - text = match.group("text") or "" - return len(marks), text - - -def parse_agents_document(text: str) -> AgentsDocument: - """Parse sectioned markdown into a document model.""" - preamble_lines: list[str] = [] - body_lines: list[str] = [] - sections: list[AgentsSection] = [] - current_heading: tuple[int, str] | None = None - - for line in text.splitlines(keepends=True): - heading = _parse_heading(line) - - if heading is None: - if current_heading is None: - preamble_lines.append(line) - else: - body_lines.append(line) - continue - - if ( - current_heading is None - and not preamble_lines - and heading[0] == 1 - and normalize_section_name(heading[1]) in {"agents", "agents.md"} - ): - preamble_lines.append(line) - continue - - if current_heading is not None: - sections.append( - build_section( - current_heading[1], - "".join(body_lines), - heading_level=current_heading[0], - ) - ) - - current_heading = heading - body_lines = [] - - if current_heading is None: - return AgentsDocument(preamble="".join(preamble_lines), sections=[]) - - sections.append( - build_section( - current_heading[1], - "".join(body_lines), - heading_level=current_heading[0], - ) - ) - - return AgentsDocument(preamble="".join(preamble_lines), sections=sections) - - -def serialize_agents_document(document: AgentsDocument) -> str: - """Serialize a parsed document back to markdown.""" - parts = [document.preamble] - - for section in document.sections: - parts.append(f"{'#' * section.heading_level} {section.heading_text}\n") - body = section.body - - if not body: - parts.append("\n") - continue - - if not body.startswith("\n"): - parts.append("\n") - - parts.append(body) - - if not body.endswith("\n"): - parts.append("\n") - - if not body.endswith("\n\n"): - parts.append("\n") - - return "".join(parts) - - -def get_section(document: AgentsDocument, name: str) -> AgentsSection | None: - """Return the first section matching the normalized name.""" - normalized_name = normalize_section_name(name) - - for section in document.sections: - if section.normalized_name == normalized_name: - return section - - return None - - -def replace_section(document: AgentsDocument, section: AgentsSection) -> AgentsDocument: - """Replace the first matching section and leave the rest unchanged.""" - sections = list(document.sections) - - for index, existing in enumerate(sections): - if existing.normalized_name == section.normalized_name: - sections[index] = section - return AgentsDocument(preamble=document.preamble, sections=sections) - - return document - - -def add_or_replace_section( - document: AgentsDocument, section: AgentsSection -) -> AgentsDocument: - """Replace the first matching section or append when absent.""" - updated = replace_section(document, section) - - if updated is not document: - return updated - - return AgentsDocument( - preamble=document.preamble, - sections=[*document.sections, section], - ) diff --git a/agentskill/lib/cli_entrypoint.py b/agentskill/lib/cli_entrypoint.py deleted file mode 100644 index caaf0ba..0000000 --- a/agentskill/lib/cli_entrypoint.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Shared CLI entrypoint helper for analyzer command modules.""" - -import argparse - -from agentskill.lib.output import run_and_output - - -def run_command_main( - *, - argv: list[str] | None, - description: str | None, - command_fn, - script_name: str, - supports_lang: bool = False, -) -> int: - parser = argparse.ArgumentParser( - description=description, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - - parser.add_argument("repo", help="Path to repository") - - if supports_lang: - parser.add_argument("--lang", help="Filter to a single language") - - parser.add_argument("--pretty", action="store_true", help="Pretty-print output") - - args = parser.parse_args(argv) - extra_kwargs = None - - if supports_lang: - extra_kwargs = {"lang_filter": args.lang} - - return run_and_output( - command_fn, - repo=args.repo, - pretty=args.pretty, - script_name=script_name, - extra_kwargs=extra_kwargs, - ) diff --git a/agentskill/lib/generate_runner.py b/agentskill/lib/generate_runner.py deleted file mode 100644 index b43ca0a..0000000 --- a/agentskill/lib/generate_runner.py +++ /dev/null @@ -1,314 +0,0 @@ -"""Direct AGENTS.md generation workflow without merge/update semantics.""" - -import sys -from pathlib import Path - -from agentskill.common.fs import validate_repo -from agentskill.lib.interactive_runner import ( - PromptIO, - StdinPromptIO, - apply_interactive_notes, - ask_generation_questions, - detect_generation_gaps, - interactive_section_notes, -) -from agentskill.lib.multifile_output import ( - SECTION_DIR, - build_root_index, - build_section_file, - section_file_path, -) -from agentskill.lib.output import validate_out_path -from agentskill.lib.output_layouts import validate_output_layout -from agentskill.lib.output_profiles import validate_output_profile -from agentskill.lib.profile_rendering import ( - build_companion_document, - companion_path, - inject_split_link, -) -from agentskill.lib.reference_flow import load_reference_documents -from agentskill.lib.reference_initialization import ( - initialize_from_references, - render_reference_metadata_block, -) -from agentskill.lib.runner import run_all -from agentskill.lib.update_feedback import load_feedback -from agentskill.lib.update_merge import merge_agents_document -from agentskill.lib.update_runner import ( - DOCUMENT_TITLE, - SECTION_ORDER, - render_agents_sections, -) - - -def _inject_reference_metadata(markdown: str, metadata_block: str) -> str: - if markdown.startswith(DOCUMENT_TITLE): - return ( - DOCUMENT_TITLE - + metadata_block - + "\n\n" - + markdown.removeprefix(DOCUMENT_TITLE) - ) - - return metadata_block + "\n\n" + markdown - - -def render_agents_markdown( - repo: Path, - *, - references: list[str] | None = None, - interactive: bool = False, - prompt_io: PromptIO | None = None, - profile: str = "concise", -) -> str: - profile = validate_output_profile(profile) - - documents = load_reference_documents(references) - analysis = run_all(str(repo)) - feedback = load_feedback(repo) - sections = render_agents_sections(repo, analysis, feedback, profile=profile) - - if interactive: - gaps = detect_generation_gaps(analysis, documents) - answers = ask_generation_questions(gaps, prompt_io or StdinPromptIO()) - sections = apply_interactive_notes(sections, interactive_section_notes(answers)) - - result = merge_agents_document( - None, - sections, - force=True, - document_preamble=DOCUMENT_TITLE, - preferred_order=SECTION_ORDER, - ) - - markdown = result.text - - if not references: - return markdown - - initialization = initialize_from_references(analysis, documents) - metadata_block = render_reference_metadata_block(initialization.metadata) - - return _inject_reference_metadata(markdown, metadata_block) - - -def generate_agents( - repo: str, - *, - out: str | None = None, - references: list[str] | None = None, - interactive: bool = False, - prompt_io: PromptIO | None = None, - profile: str = "concise", - layout: str = "single", -) -> int: - profile = validate_output_profile(profile) - layout = validate_output_layout(layout) - - try: - repo_path = validate_repo(repo) - except ValueError as exc: - print(f"Generate failed for repo {repo}: {exc}", file=sys.stderr) - return 1 - - if layout == "split": - return _generate_split( - repo_path, - out=out, - references=references, - interactive=interactive, - prompt_io=prompt_io, - profile=profile, - ) - - if layout == "multifile": - return _generate_multifile( - repo_path, - out=out, - references=references, - interactive=interactive, - prompt_io=prompt_io, - profile=profile, - ) - - try: - markdown = render_agents_markdown( - repo_path, - references=references, - interactive=interactive, - prompt_io=prompt_io, - profile=profile, - ) - - if out is not None: - output_path = validate_out_path(out) - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(markdown) - else: - print(markdown, end="") - except Exception as exc: - print(f"Generate failed for repo {repo}: {exc}", file=sys.stderr) - return 1 - - return 0 - - -def _resolve_primary_path(out: str | None, repo_path: Path) -> Path: - """Return the primary output path, defaulting to repo-local AGENTS.md.""" - if out is not None: - return validate_out_path(out) - - return repo_path / "AGENTS.md" - - -def _generate_split( - repo_path: Path, - *, - out: str | None = None, - references: list[str] | None = None, - interactive: bool = False, - prompt_io: PromptIO | None = None, - profile: str = "concise", -) -> int: - """Generate concise primary + comprehensive companion files.""" - primary_path = _resolve_primary_path(out, repo_path) - - try: - documents = load_reference_documents(references) - analysis = run_all(str(repo_path)) - feedback = load_feedback(repo_path) - - concise_sections = render_agents_sections( - repo_path, analysis, feedback, profile="concise" - ) - - if interactive: - gaps = detect_generation_gaps(analysis, documents) - answers = ask_generation_questions(gaps, prompt_io or StdinPromptIO()) - concise_sections = apply_interactive_notes( - concise_sections, interactive_section_notes(answers) - ) - - comprehensive_sections = render_agents_sections( - repo_path, analysis, feedback, profile="comprehensive" - ) - - if interactive: - comprehensive_sections = apply_interactive_notes( - comprehensive_sections, interactive_section_notes(answers) - ) - - concise_result = merge_agents_document( - None, - concise_sections, - force=True, - document_preamble=DOCUMENT_TITLE, - preferred_order=SECTION_ORDER, - ) - - comprehensive_result = merge_agents_document( - None, - comprehensive_sections, - force=True, - document_preamble=DOCUMENT_TITLE, - preferred_order=SECTION_ORDER, - ) - - primary_markdown = inject_split_link(concise_result.text, primary_path) - companion_markdown = build_companion_document(comprehensive_result.text) - - if references: - initialization = initialize_from_references(analysis, documents) - metadata_block = render_reference_metadata_block(initialization.metadata) - - primary_markdown = _inject_reference_metadata( - primary_markdown, metadata_block - ) - - companion_markdown = _inject_reference_metadata( - companion_markdown, metadata_block - ) - - comp_path = companion_path(primary_path) - - primary_path.parent.mkdir(parents=True, exist_ok=True) - primary_path.write_text(primary_markdown) - comp_path.parent.mkdir(parents=True, exist_ok=True) - comp_path.write_text(companion_markdown) - except Exception as exc: - print(f"Generate failed for repo {repo_path}: {exc}", file=sys.stderr) - return 1 - - return 0 - - -def _generate_multifile( - repo_path: Path, - *, - out: str | None = None, - references: list[str] | None = None, - interactive: bool = False, - prompt_io: PromptIO | None = None, - profile: str = "comprehensive", -) -> int: - """Generate root index + per-section markdown files.""" - primary_path = _resolve_primary_path(out, repo_path) - - try: - documents = load_reference_documents(references) - analysis = run_all(str(repo_path)) - feedback = load_feedback(repo_path) - - sections = render_agents_sections( - repo_path, analysis, feedback, profile=profile - ) - - if interactive: - gaps = detect_generation_gaps(analysis, documents) - answers = ask_generation_questions(gaps, prompt_io or StdinPromptIO()) - sections = apply_interactive_notes( - sections, interactive_section_notes(answers) - ) - - overview_body = sections.get("overview") - overview_summary = "" - - if overview_body is not None: - overview_text = overview_body.body.strip() - lines = [ - line for line in overview_text.splitlines() if not line.startswith("#") - ] - - if lines: - overview_summary = " ".join( - line.strip() for line in lines if line.strip() - ) - - active_sections = [name for name in SECTION_ORDER if name in sections] - - root_markdown = build_root_index( - primary_path, active_sections, overview_summary=overview_summary - ) - - if references: - initialization = initialize_from_references(analysis, documents) - metadata_block = render_reference_metadata_block(initialization.metadata) - root_markdown = _inject_reference_metadata(root_markdown, metadata_block) - - primary_path.parent.mkdir(parents=True, exist_ok=True) - primary_path.write_text(root_markdown) - - section_dir = primary_path.parent / SECTION_DIR - section_dir.mkdir(parents=True, exist_ok=True) - - for name in active_sections: - section = sections[name] - file_path = section_file_path(primary_path, name) - file_content = build_section_file(name, section.body) - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(file_content) - except Exception as exc: - print(f"Generate failed for repo {repo_path}: {exc}", file=sys.stderr) - return 1 - - return 0 diff --git a/agentskill/lib/interactive_runner.py b/agentskill/lib/interactive_runner.py deleted file mode 100644 index 332c867..0000000 --- a/agentskill/lib/interactive_runner.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Interactive gap detection and prompt handling for AGENTS generation.""" - -from dataclasses import dataclass -from typing import Protocol - -from agentskill.lib.agents_document import AgentsSection, build_section -from agentskill.lib.references import ReferenceDocument - -_REFERENCE_TEST_COMMAND_PATTERNS = [ - r"Run command:\s*`([^`]+)`", - r"canonical test command:\s*`([^`]+)`", -] - -_REFERENCE_COMMIT_PREFIX_PATTERNS = [ - r"Commit prefixes observed:\s*`([^`]+)`", - r"Preferred commit prefixes:\s*`([^`]+)`", -] - -_REFERENCE_MERGE_STRATEGY_PATTERNS = [ - r"Merge strategy:\s*`([^`]+)`", - r"Preferred merge strategy:\s*`([^`]+)`", -] - - -class PromptIO(Protocol): - def ask(self, prompt: str) -> str: ... - - -class StdinPromptIO: - def ask(self, prompt: str) -> str: - return input(prompt) - - -@dataclass(frozen=True) -class GenerationGap: - key: str - section: str - prompt: str - inferred_value: str | None = None - - -def _first_run_command(analysis: dict) -> str | None: - tests = analysis.get("tests", {}) - - if not isinstance(tests, dict): - return None - - for lang_data in tests.values(): - if not isinstance(lang_data, dict): - continue - - run_command = lang_data.get("run_command") - - if isinstance(run_command, str) and run_command and run_command != "unknown": - return run_command - - return None - - -def _search_reference_patterns( - documents: list[ReferenceDocument], - patterns: list[str], -) -> str | None: - import re - - values: list[str] = [] - - for document in documents: - for pattern in patterns: - match = re.search(pattern, document.content, re.IGNORECASE) - - if match is None: - continue - - value = match.group(1).strip() - - if value and value not in values: - values.append(value) - - if len(values) == 1: - return values[0] - - return None - - -def detect_generation_gaps( - analysis: dict, - reference_documents: list[ReferenceDocument] | None = None, -) -> list[GenerationGap]: - documents = reference_documents or [] - gaps: list[GenerationGap] = [] - - if _first_run_command(analysis) is None: - gaps.append( - GenerationGap( - key="test_command", - section="testing", - prompt=( - "I couldn't determine the canonical test command. " - "Enter it, or press Enter to skip: " - ), - inferred_value=_search_reference_patterns( - documents, _REFERENCE_TEST_COMMAND_PATTERNS - ), - ) - ) - - git = analysis.get("git", {}) - - if isinstance(git, dict) and "error" in git: - gaps.append( - GenerationGap( - key="commit_prefixes", - section="git", - prompt=( - "Git history is unavailable. Enter preferred commit prefixes " - "(for example: feat:, fix:, chore:), or press Enter to skip: " - ), - inferred_value=_search_reference_patterns( - documents, _REFERENCE_COMMIT_PREFIX_PATTERNS - ), - ) - ) - - gaps.append( - GenerationGap( - key="merge_strategy", - section="git", - prompt=( - "Git history is unavailable. Enter the preferred merge strategy " - "(for example: rebase, squash, merge), or press Enter to skip: " - ), - inferred_value=_search_reference_patterns( - documents, _REFERENCE_MERGE_STRATEGY_PATTERNS - ), - ) - ) - - return gaps - - -def ask_generation_questions( - gaps: list[GenerationGap], - prompt_io: PromptIO, -) -> dict[str, str]: - answers: dict[str, str] = {} - - for gap in gaps: - if gap.inferred_value is not None: - answers[gap.key] = gap.inferred_value - continue - - answer = prompt_io.ask(gap.prompt).strip() - - if answer: - answers[gap.key] = answer - - return answers - - -def interactive_section_notes(answers: dict[str, str]) -> dict[str, list[str]]: - notes: dict[str, list[str]] = {} - - test_command = answers.get("test_command") - - if test_command: - for section in ("commands and workflows", "testing"): - notes.setdefault(section, []).append( - f"Use `{test_command}` as the canonical test command." - ) - - commit_prefixes = answers.get("commit_prefixes") - - if commit_prefixes: - notes.setdefault("git", []).append( - f"Preferred commit prefixes: `{commit_prefixes}`." - ) - - merge_strategy = answers.get("merge_strategy") - - if merge_strategy: - notes.setdefault("git", []).append( - f"Preferred merge strategy: `{merge_strategy}`." - ) - - return notes - - -def apply_interactive_notes( - sections: dict[str, AgentsSection], - notes: dict[str, list[str]], -) -> dict[str, AgentsSection]: - if not notes: - return sections - - updated = dict(sections) - - for section_name, entries in notes.items(): - section = updated.get(section_name) - - if section is None or not entries: - continue - - prefix = "Interactive answers:\n" + "\n".join(f"- {entry}" for entry in entries) - body = prefix + "\n\n" + section.body - - updated[section_name] = build_section( - section.heading_text, - body, - heading_level=section.heading_level, - ) - - return updated diff --git a/agentskill/lib/logging_utils.py b/agentskill/lib/logging_utils.py deleted file mode 100644 index 3b988a9..0000000 --- a/agentskill/lib/logging_utils.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Shared logging helpers for the agentskill CLI.""" - -import logging -import sys - -LOGGER_NAME = "agentskill" -LOG_FORMAT = "%(levelname)s %(name)s: %(message)s" -HANDLER_NAME = "agentskill.stderr" - - -def get_logger() -> logging.Logger: - return logging.getLogger(LOGGER_NAME) - - -def configure_logging() -> logging.Logger: - logger = get_logger() - formatter = logging.Formatter(LOG_FORMAT) - - existing = [entry for entry in logger.handlers if entry.get_name() == HANDLER_NAME] - - for handler in existing: - logger.removeHandler(handler) - handler.close() - - handler = logging.StreamHandler(sys.stderr) - handler.set_name(HANDLER_NAME) - logger.addHandler(handler) - - handler.setFormatter(formatter) - logger.setLevel(logging.WARNING) - logger.propagate = False - return logger diff --git a/agentskill/lib/multifile_output.py b/agentskill/lib/multifile_output.py deleted file mode 100644 index 2fc85a5..0000000 --- a/agentskill/lib/multifile_output.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Multifile output helpers for per-section AGENTS generation.""" - -from pathlib import Path - -from agentskill.lib.update_runner import SECTION_HEADINGS - -SECTION_FILE_MAP: dict[str, str] = { - "overview": "01_OVERVIEW.md", - "repository structure": "02_REPOSITORY_STRUCTURE.md", - "service map": "03_SERVICE_MAP.md", - "cross-service boundaries": "04_CROSS_SERVICE_BOUNDARIES.md", - "commands and workflows": "05_COMMANDS_AND_WORKFLOWS.md", - "code formatting": "06_CODE_FORMATTING.md", - "naming conventions": "07_NAMING_CONVENTIONS.md", - "type annotations": "08_TYPE_ANNOTATIONS.md", - "imports": "09_IMPORTS.md", - "error handling": "10_ERROR_HANDLING.md", - "comments and docstrings": "11_COMMENTS_AND_DOCSTRINGS.md", - "testing": "12_TESTING.md", - "git": "13_GIT.md", - "dependencies and tooling": "14_DEPENDENCIES_AND_TOOLING.md", - "red lines": "15_RED_LINES.md", -} - -SECTION_DESCRIPTIONS: dict[str, str] = { - "overview": "repository purpose, language set, architecture summary", - "repository structure": "top-level layout and where code goes", - "service map": "service boundaries and roots", - "cross-service boundaries": "cross-service import rules and isolation", - "commands and workflows": "install, check, test, and verification commands", - "code formatting": "indentation, line length, whitespace, and multiline style", - "naming conventions": "function, class, constant, and test naming rules", - "type annotations": "annotation style, generics, and type-checker expectations", - "imports": "import grouping, ordering, and per-line rules", - "error handling": "validation errors, payload boundaries, and fallback behavior", - "comments and docstrings": "docstring and inline comment expectations", - "testing": "framework, test commands, naming, and coverage expectations", - "git": "commit prefixes, merge strategy, and branch naming", - "dependencies and tooling": "package metadata, Python floor, and detected tools", - "red lines": "non-negotiable constraints and hard boundaries", -} - -SECTION_DIR = ".agentskill" -BACKLINK_TEXT = "> Back to [`AGENTS.md`](../AGENTS.md)\n\n" - - -def section_file_path(primary_path: Path, section_name: str) -> Path: - """Return the deterministic file path for a section file.""" - filename = SECTION_FILE_MAP[section_name] - return primary_path.parent / SECTION_DIR / filename - - -def section_file_heading(section_name: str) -> str: - """Return the markdown heading for a section file.""" - heading = SECTION_HEADINGS[section_name] - number, _, title = heading.partition(" ") - return f"# {number.strip()} {title.strip()}\n\n" - - -def build_section_file( - section_name: str, body: str, include_backlink: bool = True -) -> str: - """Build a section file with heading, optional backlink, and body.""" - parts: list[str] = [] - - if include_backlink: - parts.append(BACKLINK_TEXT) - - parts.append(section_file_heading(section_name)) - parts.append(body) - - return "".join(parts) - - -def build_root_index( - primary_path: Path, - section_names: list[str], - overview_summary: str = "", -) -> str: - """Build the compact root AGENTS.md for multifile layout.""" - parts: list[str] = [] - parts.append("# AGENTS.md\n\n") - - if overview_summary: - parts.append(overview_summary.rstrip("\n") + "\n\n") - - parts.append( - "This repository uses a multifile AGENTS layout. " - "Load this file first, then open only the linked section documents you need.\n\n" - ) - parts.append("## Section Index\n\n") - - for name in section_names: - if name not in SECTION_FILE_MAP: - continue - - heading = SECTION_HEADINGS[name] - number, _, title = heading.partition(" ") - filename = SECTION_FILE_MAP[name] - description = SECTION_DESCRIPTIONS.get(name, "") - rel_path = f"{SECTION_DIR}/{filename}" - - parts.append( - f"- [{number.strip()} {title.strip()}](./{rel_path}) — {description}\n" - ) - - parts.append("\n") - - return "".join(parts) diff --git a/agentskill/lib/output.py b/agentskill/lib/output.py deleted file mode 100644 index d570107..0000000 --- a/agentskill/lib/output.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Shared JSON output helpers for CLI and command entrypoints.""" - -import json -from pathlib import Path - -from agentskill.lib.logging_utils import configure_logging -from agentskill.lib.output_schema import validate_public_output - - -def validate_out_path(out: str) -> Path: - raw_path = Path(out) - - if raw_path.is_absolute(): - raise ValueError(f"invalid output path: absolute paths are not allowed: {out}") - - base_dir = Path.cwd().resolve() - resolved = (base_dir / raw_path).resolve() - - try: - resolved.relative_to(base_dir) - except ValueError as exc: - raise ValueError( - f"invalid output path: escaping the working directory is not allowed: {out}" - ) from exc - - return resolved - - -def write_output( - data: dict, - pretty: bool = False, - out: str | None = None, - schema_mode: str | None = None, -) -> None: - configure_logging() - - if schema_mode: - validate_public_output(data, mode=schema_mode) - - indent = 2 if pretty else None - text = json.dumps(data, indent=indent) - - if out: - output_path = validate_out_path(out) - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(text + "\n") - return - - print(text) - - -def run_and_output( - command_fn, - *, - repo: str, - pretty: bool = False, - out: str | None = None, - script_name: str, - extra_kwargs: dict | None = None, -) -> int: - logger = configure_logging() - kwargs = extra_kwargs or {} - - try: - result = command_fn(repo, **kwargs) - except Exception as exc: - logger.exception("Command %s failed for repo %s", script_name, repo) - result = {"error": str(exc), "script": script_name} - - write_output(result, pretty=pretty, out=out, schema_mode="single") - - return 1 if "error" in result else 0 diff --git a/agentskill/lib/output_layouts.py b/agentskill/lib/output_layouts.py deleted file mode 100644 index 72db7ec..0000000 --- a/agentskill/lib/output_layouts.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Shared output layout contract for generate and update flows. - -Layout describes output packaging (single file, split, or multifile), -independent of profile which describes content density. -""" - -SUPPORTED_OUTPUT_LAYOUTS = ("single", "split", "multifile") -DEFAULT_OUTPUT_LAYOUT = "single" - - -def validate_output_layout(value: str) -> str: - """Normalize and validate an output layout name. - - Returns the normalized layout string on success. - - Raises ValueError for unsupported values. - """ - normalized = value.strip().lower() - - if normalized not in SUPPORTED_OUTPUT_LAYOUTS: - allowed = ", ".join(SUPPORTED_OUTPUT_LAYOUTS) - raise ValueError(f"unsupported output layout: {value!r} (allowed: {allowed})") - - return normalized diff --git a/agentskill/lib/output_profiles.py b/agentskill/lib/output_profiles.py deleted file mode 100644 index ee706be..0000000 --- a/agentskill/lib/output_profiles.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Shared output profile contract for generate and update flows.""" - -SUPPORTED_OUTPUT_PROFILES = ("concise", "comprehensive") -DEFAULT_OUTPUT_PROFILE = "concise" - - -def validate_output_profile(value: str) -> str: - """Normalize and validate an output profile name. - - Returns the normalized profile string on success. - - Raises ValueError for unsupported values. - """ - normalized = value.strip().lower() - - if normalized not in SUPPORTED_OUTPUT_PROFILES: - allowed = ", ".join(SUPPORTED_OUTPUT_PROFILES) - raise ValueError(f"unsupported output profile: {value!r} (allowed: {allowed})") - - return normalized diff --git a/agentskill/lib/output_schema.py b/agentskill/lib/output_schema.py deleted file mode 100644 index 28e87b4..0000000 --- a/agentskill/lib/output_schema.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Lightweight validation helpers for public JSON output contracts.""" - -import json -from dataclasses import dataclass - -ANALYZER_NAMES = ( - "scan", - "measure", - "config", - "git", - "graph", - "symbols", - "tests", -) - - -class OutputSchemaError(ValueError): - """Raised when a public output payload violates the JSON contract.""" - - -@dataclass(frozen=True) -class ErrorPayload: - error: str - script: str - - -def is_error_payload(data: object) -> bool: - return ( - isinstance(data, dict) - and set(data) == {"error", "script"} - and isinstance(data.get("error"), str) - and isinstance(data.get("script"), str) - ) - - -def _ensure_jsonable(data: object, *, context: str) -> None: - try: - json.dumps(data) - except TypeError as exc: - raise OutputSchemaError(f"{context} is not JSON-serializable") from exc - - -def validate_error_payload(data: object) -> None: - if not isinstance(data, dict): - raise OutputSchemaError("error payload must be a dict") - - if set(data) != {"error", "script"}: - raise OutputSchemaError( - "error payload must contain exactly 'error' and 'script'" - ) - - if not isinstance(data["error"], str): - raise OutputSchemaError("error payload field 'error' must be a string") - - if not isinstance(data["script"], str): - raise OutputSchemaError("error payload field 'script' must be a string") - - _ensure_jsonable(data, context="error payload") - - -def validate_analyzer_output(data: object, *, allow_error: bool = True) -> None: - if not isinstance(data, dict): - raise OutputSchemaError("analyzer output must be a dict") - - if "error" in data: - if not allow_error: - raise OutputSchemaError("unexpected error payload in analyzer output") - - validate_error_payload(data) - return - - _ensure_jsonable(data, context="analyzer output") - - -def validate_analyze_repo_output(data: object) -> None: - if not isinstance(data, dict): - raise OutputSchemaError("analyze output for a repo must be a dict") - - keys = set(data) - - if keys != set(ANALYZER_NAMES): - raise OutputSchemaError("analyze output must contain exactly the analyzer keys") - - for _analyzer_name, payload in data.items(): - validate_analyzer_output(payload) - - -def validate_analyze_output(data: object) -> None: - if not isinstance(data, dict): - raise OutputSchemaError("analyze output must be a dict") - - if not data: - raise OutputSchemaError("analyze output must not be empty") - - keys = set(data) - - analyzer_keys = set(ANALYZER_NAMES) - - if keys.issubset(analyzer_keys): - validate_analyze_repo_output(data) - return - - for repo_path, repo_payload in data.items(): - if not isinstance(repo_path, str): - raise OutputSchemaError("analyze output repo keys must be strings") - - validate_analyze_repo_output(repo_payload) - - -def validate_generation_output(data: object) -> None: - if not isinstance(data, dict): - raise OutputSchemaError("generation output must be a dict") - - _ensure_jsonable(data, context="generation output") - - -def validate_public_output(data: object, *, mode: str) -> None: - if mode == "single": - validate_analyzer_output(data) - return - - if mode == "analyze": - validate_analyze_output(data) - return - - if mode == "generation": - validate_generation_output(data) - return - - raise OutputSchemaError(f"unknown output validation mode: {mode}") diff --git a/agentskill/lib/parsers.py b/agentskill/lib/parsers.py deleted file mode 100644 index 7630acb..0000000 --- a/agentskill/lib/parsers.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Shared TOML and YAML parser loading with optional dependency fallback.""" - -from typing import Any - - -class ParserUnavailableError(RuntimeError): - """Raised when a required parser dependency is not installed.""" - - -_toml_module = None -_toml_checked = False - - -def _resolve_toml(): - global _toml_module, _toml_checked - - if _toml_checked: - return _toml_module - - _toml_checked = True - - try: - import tomllib # type: ignore[import-not-found] - - _toml_module = tomllib - return _toml_module - except ImportError: - pass - - try: - import tomli - - _toml_module = tomli - return _toml_module - except ImportError: - pass - - _toml_module = None - return None - - -def has_toml_support() -> bool: - return _resolve_toml() is not None - - -def load_toml(content: str) -> dict[str, Any]: - """Parse a TOML document from a string.""" - mod = _resolve_toml() - - if mod is None: - raise ParserUnavailableError( - "TOML parser unavailable: install 'tomli' for Python 3.10 " - "or use Python 3.11+ (stdlib tomllib)" - ) - - return mod.loads(content) - - -def load_toml_safe(content: str) -> dict[str, Any]: - """Parse a TOML document, returning {} on any error.""" - try: - data = load_toml(content) - return data if isinstance(data, dict) else {} - except (ParserUnavailableError, Exception): - return {} - - -_yaml_module = None -_yaml_checked = False - - -def _resolve_yaml(): - global _yaml_module, _yaml_checked - - if _yaml_checked: - return _yaml_module - - _yaml_checked = True - - try: - _yaml_module = __import__("yaml") - return _yaml_module - except ImportError: - pass - - _yaml_module = None - return None - - -def has_yaml_support() -> bool: - return _resolve_yaml() is not None - - -def load_yaml(content: str) -> Any: - """Parse a YAML document from a string using safe_load.""" - mod = _resolve_yaml() - - if mod is None: - raise ParserUnavailableError("YAML parser unavailable: install 'PyYAML'") - - return mod.safe_load(content) - - -def load_yaml_safe(content: str) -> dict[str, Any]: - """Parse a YAML document, returning {} on any error.""" - try: - data = load_yaml(content) - return data if isinstance(data, dict) else {} - except (ParserUnavailableError, Exception): - return {} diff --git a/agentskill/lib/profile_rendering.py b/agentskill/lib/profile_rendering.py deleted file mode 100644 index 5737551..0000000 --- a/agentskill/lib/profile_rendering.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Profile-aware section body assembly for generate and update flows.""" - -from dataclasses import dataclass -from pathlib import Path - - -@dataclass -class RenderedSectionBody: - """Core and expanded detail for a single section. - - ``core`` is always emitted. ``expanded`` is appended only when the - selected output profile is ``comprehensive``. - """ - - core: str - expanded: str = "" - - -def combine_section_body(profile: str, body: RenderedSectionBody) -> str: - """Return the final section text for the given profile. - - For ``concise`` only ``core`` is used. For ``comprehensive`` the - ``expanded`` text is appended. The function is deliberately small - so that callers never need to know about profile internals. - """ - if profile == "concise": - return body.core - - return body.core + body.expanded - - -COMPANION_SUFFIX = ".reference.md" -DOCUMENT_TITLE = "# AGENTS.md\n\n" -COMPANION_TITLE = "# AGENTS Reference\n\n" - - -def companion_path(primary_path: Path) -> Path: - """Return the deterministic companion file path for a split primary. - - The companion is placed beside the primary with ``.reference.md`` - inserted before the final ``.md`` extension. If the primary has no - ``.md`` extension, the companion suffix is appended directly. - """ - name = primary_path.name - - if name.lower().endswith(".md"): - stem = name[:-3] - companion_name = stem + COMPANION_SUFFIX - else: - companion_name = name + COMPANION_SUFFIX - - return primary_path.parent / companion_name - - -def companion_relative_link(primary_path: Path) -> str: - """Return the relative link text pointing from primary to companion.""" - return ( - f"[{companion_path(primary_path).name}](./{companion_path(primary_path).name})" - ) - - -def inject_split_link(markdown: str, primary_path: Path) -> str: - """Insert a reference link near the top of the primary markdown. - - The link is inserted immediately after the title heading if the - document starts with ``# AGENTS.md``, otherwise it is prepended. - """ - link = f"> Extended reference: {companion_relative_link(primary_path)}\n" - - if markdown.startswith(DOCUMENT_TITLE): - return DOCUMENT_TITLE + link + "\n" + markdown.removeprefix(DOCUMENT_TITLE) - - return link + "\n" + markdown - - -def build_companion_document(comprehensive_markdown: str) -> str: - """Build the companion document from the comprehensive markdown. - - Replaces the ``# AGENTS.md`` title with ``# AGENTS Reference`` and - adds a short note that this file is the extended companion. - """ - opening = "> Extended reference document for the main AGENTS.md.\n\n" - - if comprehensive_markdown.startswith(DOCUMENT_TITLE): - return ( - opening - + COMPANION_TITLE - + comprehensive_markdown.removeprefix(DOCUMENT_TITLE) - ) - - return opening + comprehensive_markdown diff --git a/agentskill/lib/reference_adaptation.py b/agentskill/lib/reference_adaptation.py deleted file mode 100644 index 7c2e8c4..0000000 --- a/agentskill/lib/reference_adaptation.py +++ /dev/null @@ -1,378 +0,0 @@ -"""Reference adaptation engine for comparing reference AGENTS.md against target analysis.""" - -from dataclasses import dataclass - -from agentskill.lib.references import ReferenceDocument, ReferenceSource - - -@dataclass(frozen=True) -class ReferenceSection: - heading: str - body: str - level: int - - -@dataclass(frozen=True) -class AdaptedConvention: - section: ReferenceSection - category: str - status: str - reason: str - - -@dataclass(frozen=True) -class ReferenceAdaptationResult: - source: ReferenceSource - conventions: list[AdaptedConvention] - - @property - def applicable(self) -> list[AdaptedConvention]: - return [c for c in self.conventions if c.status == "applicable"] - - @property - def mismatched(self) -> list[AdaptedConvention]: - return [c for c in self.conventions if c.status == "mismatched"] - - @property - def uncertain(self) -> list[AdaptedConvention]: - return [c for c in self.conventions if c.status == "uncertain"] - - @property - def ignored(self) -> list[AdaptedConvention]: - return [c for c in self.conventions if c.status == "ignored"] - - -_LANGUAGE_KEYWORDS = { - "python": ["python", ".py", "pyproject.toml"], - "typescript": ["typescript", ".ts", "tsconfig"], - "javascript": ["javascript", ".js", "js"], - "go": ["go", ".go", "go.mod"], - "rust": ["rust", ".rs", "cargo"], - "java": ["java", ".java", "maven", "gradle"], - "ruby": ["ruby", ".rb", "gemfile"], - "php": ["php", ".php", "composer"], - "csharp": ["csharp", ".cs", ".csproj"], - "cpp": ["c++", ".cpp", ".hpp", "cmake"], - "shell": ["shell", "bash", ".sh"], -} - -_TOOL_KEYWORDS = { - "ruff": ["ruff"], - "black": ["black"], - "mypy": ["mypy"], - "prettier": ["prettier"], - "eslint": ["eslint"], - "gofmt": ["gofmt"], - "golangci-lint": ["golangci"], - "rustfmt": ["rustfmt"], - "clippy": ["clippy"], -} - -_TEST_KEYWORDS = { - "pytest": ["pytest"], - "unittest": ["unittest"], - "jest": ["jest"], - "vitest": ["vitest"], - "go test": ["go test"], - "cargo test": ["cargo test"], - "rspec": ["rspec"], -} - -_CATEGORY_KEYWORDS = { - "directory_structure": [ - "directory", - "structure", - "src/", - "tests/", - "apps/", - "packages/", - ], - "testing": ["test", "testing", "pytest", "jest", "unittest", "vitest", "rspec"], - "formatter": [ - "format", - "formatter", - "ruff", - "black", - "prettier", - "gofmt", - "rustfmt", - ], - "linter": ["lint", "linter", "ruff", "eslint", "golangci", "clippy"], - "type_checker": ["type", "mypy", "typescript"], - "git": ["git", "commit", "branch", "merge"], -} - - -def split_markdown_sections(content: str) -> list[ReferenceSection]: - import re - - lines = content.splitlines() - sections: list[ReferenceSection] = [] - current_heading = "" - current_level = 0 - current_body: list[str] = [] - - for line in lines: - m = re.match(r"^(#{1,6})\s+(.*)$", line) - if m: - if current_body or current_heading: - sections.append( - ReferenceSection( - heading=current_heading, - body="\n".join(current_body).strip(), - level=current_level, - ) - ) - current_level = len(m.group(1)) - current_heading = m.group(2) - current_body = [] - else: - current_body.append(line) - - if current_body or current_heading: - sections.append( - ReferenceSection( - heading=current_heading, - body="\n".join(current_body).strip(), - level=current_level, - ) - ) - elif not sections: - sections.append(ReferenceSection(heading="", body=content, level=0)) - - return sections - - -def _detect_category(section: ReferenceSection) -> str: - text = (section.heading + " " + section.body).lower() - - for category, keywords in _CATEGORY_KEYWORDS.items(): - for kw in keywords: - if kw.lower() in text: - return category - - return "unknown" - - -def _extract_languages(text: str) -> set[str]: - found = set() - text_lower = text.lower() - - for lang, keywords in _LANGUAGE_KEYWORDS.items(): - for kw in keywords: - if kw.lower() in text_lower: - found.add(lang) - break - - return found - - -def _extract_tools(text: str) -> set[str]: - found = set() - text_lower = text.lower() - - for tool, keywords in _TOOL_KEYWORDS.items(): - for kw in keywords: - if kw.lower() in text_lower: - found.add(tool) - break - - for tool, keywords in _TEST_KEYWORDS.items(): - for kw in keywords: - if kw.lower() in text_lower: - found.add(tool) - break - - return found - - -def _target_languages(target_analysis: dict) -> set[str]: - found: set[str] = set() - summary = target_analysis.get("scan", {}).get("summary", {}) - - for lang in summary.get("languages", []): - lang_lower = lang.lower() - for known in _LANGUAGE_KEYWORDS: - if known in lang_lower or lang_lower in known: - found.add(known) - - return found - - -def _target_tools(target_analysis: dict) -> set[str]: - found: set[str] = set() - config = target_analysis.get("config", {}) - - for _lang_key, lang_data in config.items(): - if isinstance(lang_data, dict): - for tool_type in ("formatter", "linter", "type_checker"): - tool_info = lang_data.get(tool_type) - if isinstance(tool_info, dict) and tool_info.get("name"): - found.add(tool_info["name"].lower()) - - return found - - -def _target_test_frameworks(target_analysis: dict) -> set[str]: - found: set[str] = set() - tests = target_analysis.get("tests", {}) - - if isinstance(tests, dict): - for fw_info in tests.get("frameworks", []): - if isinstance(fw_info, dict) and fw_info.get("name"): - found.add(fw_info["name"].lower()) - - return found - - -def _target_paths(target_analysis: dict) -> set[str]: - found: set[str] = set() - tree = target_analysis.get("scan", {}).get("tree", []) - - for entry in tree: - path = entry.get("path", "") - if path: - found.add(path) - - return found - - -def _check_directory_paths( - section: ReferenceSection, target_analysis: dict -) -> tuple[str, str]: - target_paths = _target_paths(target_analysis) - body = section.body - - import re - - referenced = re.findall(r"[\w\-]+", body) - referenced = [r for r in referenced if len(r) > 1] - matched = [p for p in referenced if any(p in t.split("/") for t in target_paths)] - - if matched: - return ( - "applicable", - f"referenced paths found in target: {', '.join(matched[:3])}", - ) - - if referenced: - return "mismatched", "referenced paths not found in target scan tree" - - return "uncertain", "no directory paths referenced" - - -def _classify_section( - section: ReferenceSection, target_analysis: dict -) -> AdaptedConvention: - category = _detect_category(section) - text = (section.heading + " " + section.body).lower() - - if category == "directory_structure": - status, reason = _check_directory_paths(section, target_analysis) - - return AdaptedConvention( - section=section, - category=category, - status=status, - reason=reason, - ) - - if category == "git": - git_data = target_analysis.get("git") - - if git_data: - return AdaptedConvention( - section=section, - category=category, - status="applicable", - reason="git analysis detected in target", - ) - - return AdaptedConvention( - section=section, - category=category, - status="uncertain", - reason="target analysis missing git data", - ) - - ref_languages = _extract_languages(text) - ref_tools = _extract_tools(text) - - if ref_languages: - target_langs = _target_languages(target_analysis) - - if target_langs: - overlap = ref_languages & target_langs - if overlap: - return AdaptedConvention( - section=section, - category="language", - status="applicable", - reason=f"language {overlap.pop()} found in target scan summary", - ) - - return AdaptedConvention( - section=section, - category="language", - status="mismatched", - reason=f"language {ref_languages.pop()} not found in target scan summary", - ) - - return AdaptedConvention( - section=section, - category="language", - status="uncertain", - reason="target analysis missing scan summary languages", - ) - - if ref_tools: - target_tools = _target_tools(target_analysis) - target_tests = _target_test_frameworks(target_analysis) - all_target_tools = target_tools | target_tests - - if all_target_tools: - overlap = ref_tools & all_target_tools - if overlap: - return AdaptedConvention( - section=section, - category=category if category != "unknown" else "tool", - status="applicable", - reason=f"tool {overlap.pop()} detected in target analysis", - ) - - return AdaptedConvention( - section=section, - category=category if category != "unknown" else "tool", - status="mismatched", - reason=f"tool {ref_tools.pop()} mentioned but not detected in target", - ) - - return AdaptedConvention( - section=section, - category=category if category != "unknown" else "tool", - status="uncertain", - reason="target analysis missing config data", - ) - - return AdaptedConvention( - section=section, - category="unknown", - status="uncertain", - reason="no recognizable language, tool, or directory keywords", - ) - - -def adapt_reference( - document: ReferenceDocument, target_analysis: dict -) -> ReferenceAdaptationResult: - sections = split_markdown_sections(document.content) - conventions = [_classify_section(s, target_analysis) for s in sections] - - return ReferenceAdaptationResult(source=document.source, conventions=conventions) - - -def adapt_references( - documents: list[ReferenceDocument], - target_analysis: dict, -) -> list[ReferenceAdaptationResult]: - return [adapt_reference(d, target_analysis) for d in documents] diff --git a/agentskill/lib/reference_flow.py b/agentskill/lib/reference_flow.py deleted file mode 100644 index fcb837f..0000000 --- a/agentskill/lib/reference_flow.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Shared reference normalization and loading helpers for CLI flows.""" - -from pathlib import Path - -from agentskill.lib.reference_initialization import successful_reference_documents -from agentskill.lib.references import ( - REFERENCE_KIND_LOCAL, - REFERENCE_KIND_REMOTE, - ReferenceDocument, - ReferenceLoadResult, - ReferenceSource, - load_local_reference, - load_remote_reference, -) - -REMOTE_REFERENCE_PREFIXES = ( - "http://", - "https://", - "ssh://", - "git@", -) - - -def _reference_kind(value: str) -> str: - if value.startswith(REMOTE_REFERENCE_PREFIXES): - return REFERENCE_KIND_REMOTE - - return REFERENCE_KIND_LOCAL - - -def _reference_identity(source: ReferenceSource) -> tuple[str, str]: - if source.kind == REFERENCE_KIND_LOCAL: - return source.kind, str(Path(source.value).resolve()) - - return source.kind, source.value - - -def normalize_reference_sources( - references: list[str] | None, -) -> list[ReferenceSource]: - if not references: - return [] - - sources = [ - ReferenceSource(kind=_reference_kind(reference), value=reference) - for reference in references - ] - - seen: set[tuple[str, str]] = set() - - for source in sources: - identity = _reference_identity(source) - - if identity in seen: - raise ValueError(f"duplicate reference source: {source.value}") - - seen.add(identity) - - return sources - - -def load_reference_results(references: list[str] | None) -> list[ReferenceLoadResult]: - results: list[ReferenceLoadResult] = [] - - for source in normalize_reference_sources(references): - if source.kind == REFERENCE_KIND_REMOTE: - results.append(load_remote_reference(source)) - else: - results.append(load_local_reference(source)) - - return results - - -def load_reference_documents(references: list[str] | None) -> list[ReferenceDocument]: - results = load_reference_results(references) - errors = [result.error for result in results if result.error is not None] - - if errors: - raise ValueError("; ".join(errors)) - - return successful_reference_documents(results) diff --git a/agentskill/lib/reference_initialization.py b/agentskill/lib/reference_initialization.py deleted file mode 100644 index 8bb53cd..0000000 --- a/agentskill/lib/reference_initialization.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Empty-project initialization from references and generated reference metadata.""" - -import json -from dataclasses import dataclass, field - -from agentskill.lib.reference_adaptation import ( - ReferenceAdaptationResult, - adapt_references, -) -from agentskill.lib.reference_questions import ( - ReferenceQuestion, - generate_reference_questions, -) -from agentskill.lib.references import ( - ReferenceDocument, - ReferenceLoadResult, - ReferenceMetadata, -) - -AGENTSKILL_VERSION = "1.4.0" - - -def successful_reference_documents( - results: list[ReferenceLoadResult], -) -> list[ReferenceDocument]: - return [r.document for r in results if r.ok and r.document is not None] - - -def is_empty_target(target_analysis: dict) -> bool: - scan = target_analysis.get("scan", {}) - summary = scan.get("summary", {}) - total_files = summary.get("total_files", 0) - - if total_files > 0: - return False - - tree = scan.get("tree", []) - - if tree: - return False - - has_config = bool(target_analysis.get("config")) - has_git = bool(target_analysis.get("git")) - has_tests = bool(target_analysis.get("tests")) - - return not (has_config or has_git or has_tests) - - -def build_reference_metadata( - documents: list[ReferenceDocument], - agentskill_version: str, -) -> ReferenceMetadata: - sources: list[dict] = [] - - for doc in documents: - entry: dict = { - "kind": doc.source.kind, - "value": doc.source.value, - "source_path": doc.source_path, - } - - if doc.commit_sha is not None: - entry["commit_sha"] = doc.commit_sha - - if doc.source.label is not None: - entry["label"] = doc.source.label - - sources.append(entry) - - return ReferenceMetadata(agentskill_version=agentskill_version, sources=sources) - - -def render_reference_metadata_block(metadata: ReferenceMetadata) -> str: - data = metadata.to_dict() - json_str = json.dumps(data, indent=2) - - return f"" - - -@dataclass(frozen=True) -class ReferenceInitializationResult: - is_reference_derived: bool - adapted_references: list[ReferenceAdaptationResult] - questions: list[ReferenceQuestion] - metadata: ReferenceMetadata - usable_reference_count: int = 0 - warnings: list[str] = field(default_factory=list) - - -def initialize_from_references( - target_analysis: dict, - documents: list[ReferenceDocument], - *, - agentskill_version: str = AGENTSKILL_VERSION, -) -> ReferenceInitializationResult: - is_empty = is_empty_target(target_analysis) - metadata = build_reference_metadata(documents, agentskill_version) - warnings: list[str] = [] - - adapted: list[ReferenceAdaptationResult] = [] - - if documents: - adapted = adapt_references(documents, target_analysis) - else: - warnings.append("no reference documents provided") - - questions = generate_reference_questions(adapted, target_analysis=target_analysis) - - if is_empty and not any( - c.status == "applicable" for r in adapted for c in r.conventions - ): - warnings.append("empty target with no applicable reference conventions") - - return ReferenceInitializationResult( - is_reference_derived=is_empty, - adapted_references=adapted, - questions=questions, - metadata=metadata, - usable_reference_count=len(documents), - warnings=warnings, - ) diff --git a/agentskill/lib/reference_questions.py b/agentskill/lib/reference_questions.py deleted file mode 100644 index 8dd2ff2..0000000 --- a/agentskill/lib/reference_questions.py +++ /dev/null @@ -1,438 +0,0 @@ -"""Gap detection and targeted question generation from reference adaptation results.""" - -from dataclasses import dataclass - -from agentskill.lib.reference_adaptation import ( - AdaptedConvention, - ReferenceAdaptationResult, -) -from agentskill.lib.references import ReferenceSource - -QUESTION_CATEGORY_LANGUAGE = "language" -QUESTION_CATEGORY_FORMATTER = "formatter" -QUESTION_CATEGORY_LINTER = "linter" -QUESTION_CATEGORY_TYPE_CHECKER = "type_checker" -QUESTION_CATEGORY_TESTING = "testing" -QUESTION_CATEGORY_DIRECTORY_STRUCTURE = "directory_structure" -QUESTION_CATEGORY_CONFLICT = "conflict" -QUESTION_CATEGORY_UNKNOWN = "unknown" - - -_KNOWN_TOOLS: dict[str, set[str]] = { - "testing": { - "pytest", - "unittest", - "jest", - "vitest", - "go test", - "cargo test", - "rspec", - }, - "formatter": {"ruff", "black", "prettier", "gofmt", "rustfmt"}, - "linter": {"ruff", "eslint", "golangci-lint", "clippy"}, - "type_checker": {"mypy", "pyright", "typescript"}, -} - - -_ECOSYSTEM_MAP: dict[str, set[str]] = { - "python": {"pytest", "unittest", "ruff", "black", "mypy", "pyright"}, - "typescript": {"jest", "vitest", "eslint", "prettier", "typescript"}, - "javascript": {"jest", "vitest", "eslint", "prettier"}, - "go": {"go test", "gofmt", "golangci-lint"}, - "rust": {"cargo test", "rustfmt", "clippy"}, - "ruby": {"rspec"}, -} - - -_LANGUAGE_KEYWORDS = { - "python": ["python", ".py", "pyproject.toml"], - "typescript": ["typescript", ".ts", "tsconfig"], - "javascript": ["javascript", ".js"], - "go": ["go", ".go", "go.mod"], - "rust": ["rust", ".rs", "cargo"], -} - - -def _extract_known_tools(text: str) -> set[str]: - found: set[str] = set() - text_lower = text.lower() - - for _cat, tools in _KNOWN_TOOLS.items(): - for tool in tools: - if tool in text_lower: - found.add(tool) - - return found - - -def _extract_section_languages(text: str) -> set[str]: - found: set[str] = set() - text_lower = text.lower() - - for lang, keywords in _LANGUAGE_KEYWORDS.items(): - for kw in keywords: - if kw.lower() in text_lower: - found.add(lang) - break - - return found - - -def _target_languages(target_analysis: dict) -> set[str]: - found: set[str] = set() - summary = target_analysis.get("scan", {}).get("summary", {}) - - for lang in summary.get("languages", []): - lang_lower = lang.lower() - for known in _LANGUAGE_KEYWORDS: - if known in lang_lower or lang_lower in known: - found.add(known) - - return found - - -def _same_ecosystem(lang: str, tool: str) -> bool: - return tool in _ECOSYSTEM_MAP.get(lang, set()) - - -@dataclass(frozen=True) -class ReferenceQuestion: - section: str - question: str - reason: str - category: str - source: ReferenceSource | None = None - blocking: bool = False - options: list[str] | None = None - - def to_dict(self) -> dict: - d: dict = { - "section": self.section, - "question": self.question, - "reason": self.reason, - "category": self.category, - "blocking": self.blocking, - } - - if self.source is not None: - d["source"] = self.source.to_dict() - - if self.options is not None: - d["options"] = self.options - - return d - - -def _question_from_uncertain( - conv: AdaptedConvention, - source: ReferenceSource | None, - target_analysis: dict | None, -) -> ReferenceQuestion | None: - cat = conv.category - text = (conv.section.heading + " " + conv.section.body).lower() - tools = _extract_known_tools(text) - - if cat in ("testing",): - tool = next((t for t in tools if t in _KNOWN_TOOLS["testing"]), None) - - if tool: - return ReferenceQuestion( - section=conv.section.heading, - question=f"The reference repo uses {tool}, but the target test framework is unclear. Should generated instructions mention {tool}, another framework, or omit test guidance?", - reason=conv.reason, - category=QUESTION_CATEGORY_TESTING, - source=source, - blocking=False, - options=[tool, "another framework", "omit test guidance"], - ) - - if cat in ("formatter",): - tool = next((t for t in tools if t in _KNOWN_TOOLS["formatter"]), None) - - if tool: - return ReferenceQuestion( - section=conv.section.heading, - question=f"The reference repo uses {tool}, but the target config does not show {tool}. Should this convention be applied?", - reason=conv.reason, - category=QUESTION_CATEGORY_FORMATTER, - source=source, - blocking=False, - options=["apply", "omit", "use target-detected tooling only"], - ) - - if cat in ("linter",): - tool = next((t for t in tools if t in _KNOWN_TOOLS["linter"]), None) - - if tool: - return ReferenceQuestion( - section=conv.section.heading, - question=f"The reference repo uses {tool}, but the target config does not show {tool}. Should this convention be applied?", - reason=conv.reason, - category=QUESTION_CATEGORY_LINTER, - source=source, - blocking=False, - options=["apply", "omit", "use target-detected tooling only"], - ) - - if cat == "type_checker": - tool = next((t for t in tools if t in _KNOWN_TOOLS["type_checker"]), None) - - if tool: - return ReferenceQuestion( - section=conv.section.heading, - question=f"The reference repo uses {tool}, but the target config does not show {tool}. Should this convention be applied?", - reason=conv.reason, - category=QUESTION_CATEGORY_TYPE_CHECKER, - source=source, - blocking=False, - options=["apply", "omit", "use target-detected tooling only"], - ) - - if cat == "directory_structure": - return ReferenceQuestion( - section=conv.section.heading, - question="The reference repo mentions directory paths, but the target structure is unclear. Should generated instructions include these directories?", - reason=conv.reason, - category=QUESTION_CATEGORY_DIRECTORY_STRUCTURE, - source=source, - blocking=False, - options=["include", "omit", "ask later"], - ) - - if cat == "language": - return ReferenceQuestion( - section=conv.section.heading, - question="The reference mentions a language, but the target language analysis is missing. Should the language convention be applied?", - reason=conv.reason, - category=QUESTION_CATEGORY_LANGUAGE, - source=source, - blocking=False, - options=["apply", "omit", "review manually"], - ) - - if cat == "unknown" and conv.section.body.strip(): - return ReferenceQuestion( - section=conv.section.heading, - question=f"The reference section '{conv.section.heading}' could not be matched to detected target conventions. Should it influence the generated AGENTS.md?", - reason=conv.reason, - category=QUESTION_CATEGORY_UNKNOWN, - source=source, - blocking=False, - options=["use it", "ignore it", "review manually"], - ) - - return None - - -def _question_from_mismatch( - conv: AdaptedConvention, - source: ReferenceSource | None, - target_analysis: dict | None, -) -> ReferenceQuestion | None: - cat = conv.category - text = (conv.section.heading + " " + conv.section.body).lower() - tools = _extract_known_tools(text) - - if cat == "language": - ref_langs = _extract_section_languages(text) - - if target_analysis: - target_langs = _target_languages(target_analysis) - - if ref_langs and target_langs and not (ref_langs & target_langs): - return None - - if cat in ("testing",): - tool = next((t for t in tools if t in _KNOWN_TOOLS["testing"]), None) - - if tool: - if target_analysis: - target_langs = _target_languages(target_analysis) - relevant = any(_same_ecosystem(lang, tool) for lang in target_langs) - - if not relevant: - return None - - return ReferenceQuestion( - section=conv.section.heading, - question=f"The reference repo uses {tool}, but a different test framework is detected in the target. Should this convention be applied?", - reason=conv.reason, - category=QUESTION_CATEGORY_TESTING, - source=source, - blocking=False, - options=["apply", "omit", "use target-detected tooling only"], - ) - - if cat in ("formatter",): - tool = next((t for t in tools if t in _KNOWN_TOOLS["formatter"]), None) - - if tool: - if target_analysis: - target_langs = _target_languages(target_analysis) - relevant = any(_same_ecosystem(lang, tool) for lang in target_langs) - - if not relevant: - return None - - return ReferenceQuestion( - section=conv.section.heading, - question=f"The reference repo uses {tool}, but the target config does not show {tool}. Should this convention be applied?", - reason=conv.reason, - category=QUESTION_CATEGORY_FORMATTER, - source=source, - blocking=False, - options=["apply", "omit", "use target-detected tooling only"], - ) - - if cat in ("linter",): - tool = next((t for t in tools if t in _KNOWN_TOOLS["linter"]), None) - - if tool: - if target_analysis: - target_langs = _target_languages(target_analysis) - relevant = any(_same_ecosystem(lang, tool) for lang in target_langs) - - if not relevant: - return None - - return ReferenceQuestion( - section=conv.section.heading, - question=f"The reference repo uses {tool}, but the target config does not show {tool}. Should this convention be applied?", - reason=conv.reason, - category=QUESTION_CATEGORY_LINTER, - source=source, - blocking=False, - options=["apply", "omit", "use target-detected tooling only"], - ) - - if cat == "directory_structure": - return ReferenceQuestion( - section=conv.section.heading, - question="The reference repo mentions directory paths not found in the target. Should generated instructions include these directories?", - reason=conv.reason, - category=QUESTION_CATEGORY_DIRECTORY_STRUCTURE, - source=source, - blocking=False, - options=["include", "omit", "ask later"], - ) - - return None - - -def _detect_conflicts( - adaptations: list[ReferenceAdaptationResult], - target_analysis: dict | None, -) -> list[ReferenceQuestion]: - by_category: dict[str, list[tuple[AdaptedConvention, ReferenceSource | None]]] = {} - - for result in adaptations: - for conv in result.conventions: - if conv.status not in ("uncertain", "mismatched"): - continue - - if conv.category not in by_category: - by_category[conv.category] = [] - - by_category[conv.category].append((conv, result.source)) - - questions: list[ReferenceQuestion] = [] - - for cat, entries in by_category.items(): - if len(entries) < 2: - continue - - all_tools: set[str] = set() - - for conv, _src in entries: - text = (conv.section.heading + " " + conv.section.body).lower() - all_tools |= _extract_known_tools(text) - - cat_tools: set[str] = set() - - tool_cat_map: dict[str, str] = {} - - for tool in all_tools: - for tc, tools in _KNOWN_TOOLS.items(): - if tool in tools: - cat_tools.add(tool) - tool_cat_map[tool] = tc - break - - by_tool_cat: dict[str, set[str]] = {} - - for tool in cat_tools: - tc = tool_cat_map[tool] - - if tc not in by_tool_cat: - by_tool_cat[tc] = set() - - by_tool_cat[tc].add(tool) - - for tc, conflicting in by_tool_cat.items(): - if len(conflicting) < 2: - continue - - sorted_tools = sorted(conflicting) - tool_list = ", ".join(sorted_tools) - - qcat = QUESTION_CATEGORY_TESTING - if tc == "formatter": - qcat = QUESTION_CATEGORY_FORMATTER - elif tc == "linter": - qcat = QUESTION_CATEGORY_LINTER - elif tc == "type_checker": - qcat = QUESTION_CATEGORY_TYPE_CHECKER - - options = list(sorted_tools) + ["omit", "use target-detected tooling only"] - - questions.append( - ReferenceQuestion( - section=cat, - question=f"Multiple references suggest different {qcat} conventions: {tool_list}. Which should be used?", - reason=f"conflicting {qcat} tools across references", - category=QUESTION_CATEGORY_CONFLICT, - blocking=False, - options=options, - ) - ) - - return questions - - -def _dedup_key(q: ReferenceQuestion) -> tuple: - return (q.category, q.section, q.question) - - -def generate_reference_questions( - adaptations: list[ReferenceAdaptationResult], - target_analysis: dict | None = None, -) -> list[ReferenceQuestion]: - questions: list[ReferenceQuestion] = [] - seen: set[tuple] = set() - - for result in adaptations: - for conv in result.conventions: - q: ReferenceQuestion | None = None - - if conv.status == "uncertain": - q = _question_from_uncertain(conv, result.source, target_analysis) - elif conv.status == "mismatched": - q = _question_from_mismatch(conv, result.source, target_analysis) - - if q is not None: - key = _dedup_key(q) - - if key not in seen: - seen.add(key) - questions.append(q) - - conflicts = _detect_conflicts(adaptations, target_analysis) - - for q in conflicts: - key = _dedup_key(q) - - if key not in seen: - seen.add(key) - questions.append(q) - - return questions diff --git a/agentskill/lib/references.py b/agentskill/lib/references.py deleted file mode 100644 index a5434d1..0000000 --- a/agentskill/lib/references.py +++ /dev/null @@ -1,245 +0,0 @@ -"""Reference repository data models for 0.5.0 reference loading.""" - -from dataclasses import dataclass, field -from pathlib import Path -from subprocess import run -from tempfile import TemporaryDirectory - -from agentskill.common.fs import read_text - -REFERENCE_KIND_LOCAL = "local" -REFERENCE_KIND_REMOTE = "remote" -SUPPORTED_REFERENCE_KINDS = {REFERENCE_KIND_LOCAL, REFERENCE_KIND_REMOTE} -REFERENCE_AGENTS_FILENAME = "AGENTS.md" -REMOTE_REFERENCE_GIT_TIMEOUT_SECONDS = 60 - - -@dataclass(frozen=True) -class ReferenceSource: - kind: str - value: str - label: str | None = None - - def __post_init__(self) -> None: - if self.kind not in SUPPORTED_REFERENCE_KINDS: - raise ValueError(f"unsupported reference kind: {self.kind!r}") - - if not self.value: - raise ValueError("reference value must not be empty") - - def to_dict(self) -> dict: - data: dict = {"kind": self.kind, "value": self.value} - - if self.label is not None: - data["label"] = self.label - - return data - - -@dataclass(frozen=True) -class ReferenceDocument: - source: ReferenceSource - content: str - source_path: str = "AGENTS.md" - version: str | None = None - commit_sha: str | None = None - - def to_dict(self) -> dict: - data: dict = { - "source": self.source.to_dict(), - "content": self.content, - "source_path": self.source_path, - } - - if self.version is not None: - data["version"] = self.version - - if self.commit_sha is not None: - data["commit_sha"] = self.commit_sha - - return data - - -@dataclass(frozen=True) -class ReferenceLoadResult: - source: ReferenceSource - document: ReferenceDocument | None = None - error: str | None = None - - def __post_init__(self) -> None: - has_doc = self.document is not None - has_err = self.error is not None - - if has_doc and has_err: - raise ValueError("ReferenceLoadResult cannot have both document and error") - - if not has_doc and not has_err: - raise ValueError("ReferenceLoadResult must have either document or error") - - @property - def ok(self) -> bool: - return self.document is not None - - def to_dict(self) -> dict: - data: dict = {"source": self.source.to_dict()} - - if self.ok: - data["document"] = self.document.to_dict() # type: ignore[union-attr] - else: - data["error"] = self.error - - return data - - -@dataclass(frozen=True) -class ReferenceMetadata: - agentskill_version: str - sources: list[dict] = field(default_factory=list) - - def to_dict(self) -> dict: - return { - "agentskill_version": self.agentskill_version, - "references": list(self.sources), - } - - -def load_local_reference(source: ReferenceSource) -> ReferenceLoadResult: - if source.kind != REFERENCE_KIND_LOCAL: - return ReferenceLoadResult( - source=source, - error=f"unsupported local reference source kind: {source.kind}", - ) - - root = Path(source.value) - - if not root.exists(): - return ReferenceLoadResult( - source=source, - error=f"reference path does not exist: {source.value}", - ) - - if not root.is_dir(): - return ReferenceLoadResult( - source=source, - error=f"reference path is not a directory: {source.value}", - ) - - agents_path = root / REFERENCE_AGENTS_FILENAME - - if not agents_path.exists(): - return ReferenceLoadResult( - source=source, - error=f"AGENTS.md not found in reference repository: {source.value}", - ) - - content = read_text(agents_path) - - if not content: - return ReferenceLoadResult( - source=source, - error=f"AGENTS.md is empty in reference repository: {source.value}", - ) - - if not content.strip(): - return ReferenceLoadResult( - source=source, - error=f"AGENTS.md is empty in reference repository: {source.value}", - ) - - doc = ReferenceDocument( - source=source, - content=content, - source_path=REFERENCE_AGENTS_FILENAME, - ) - - return ReferenceLoadResult(source=source, document=doc) - - -def load_local_references(sources: list[ReferenceSource]) -> list[ReferenceLoadResult]: - return [load_local_reference(s) for s in sources] - - -def _run_git(cmd: list[str], cwd: Path | None = None) -> tuple[int, str, str]: - try: - proc = run( - cmd, - cwd=cwd, - capture_output=True, - text=True, - timeout=REMOTE_REFERENCE_GIT_TIMEOUT_SECONDS, - ) - - return proc.returncode, proc.stdout, proc.stderr - except FileNotFoundError: - return 1, "", "git executable not found" - except Exception as exc: - return 1, "", str(exc) - - -def load_remote_reference(source: ReferenceSource) -> ReferenceLoadResult: - if source.kind != REFERENCE_KIND_REMOTE: - return ReferenceLoadResult( - source=source, - error=f"unsupported remote reference source kind: {source.kind}", - ) - - if not source.value: - return ReferenceLoadResult( - source=source, - error="remote reference URL is empty", - ) - - with TemporaryDirectory() as tmp: - clone_dir = Path(tmp) / "repo" - - rc, _, stderr = _run_git( - [ - "git", - "clone", - "--depth", - "1", - source.value, - str(clone_dir), - ] - ) - - if rc != 0: - return ReferenceLoadResult( - source=source, - error=f"failed to clone remote reference repository: {source.value}", - ) - - commit_sha: str | None = None - rc, stdout, _ = _run_git(["git", "rev-parse", "HEAD"], cwd=clone_dir) - - if rc == 0 and stdout.strip(): - commit_sha = stdout.strip() - - agents_path = clone_dir / REFERENCE_AGENTS_FILENAME - - if not agents_path.exists(): - return ReferenceLoadResult( - source=source, - error=f"AGENTS.md not found in remote reference repository: {source.value}", - ) - - content = read_text(agents_path) - - if not content or not content.strip(): - return ReferenceLoadResult( - source=source, - error=f"AGENTS.md is empty in remote reference repository: {source.value}", - ) - - doc = ReferenceDocument( - source=source, - content=content, - source_path=REFERENCE_AGENTS_FILENAME, - commit_sha=commit_sha, - ) - - return ReferenceLoadResult(source=source, document=doc) - - -def load_remote_references(sources: list[ReferenceSource]) -> list[ReferenceLoadResult]: - return [load_remote_reference(s) for s in sources] diff --git a/agentskill/lib/runner.py b/agentskill/lib/runner.py deleted file mode 100644 index c0f55ea..0000000 --- a/agentskill/lib/runner.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Aggregate analyzer execution for the top-level CLI.""" - -from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait -from time import monotonic - -from agentskill.commands import config as config_command -from agentskill.commands import git as git_command -from agentskill.commands import graph as graph_command -from agentskill.commands import measure as measure_command -from agentskill.commands import scan as scan_command -from agentskill.commands import symbols as symbols_command -from agentskill.commands import tests as tests_command -from agentskill.lib.logging_utils import get_logger -from agentskill.lib.reference_flow import load_reference_documents - -COMMANDS: dict[str, dict] = { - "scan": { - "fn": scan_command.scan, - "supports_lang": True, - }, - "measure": { - "fn": measure_command.measure, - "supports_lang": True, - }, - "config": { - "fn": config_command.detect, - "supports_lang": False, - }, - "git": { - "fn": git_command.analyze, - "supports_lang": False, - }, - "graph": { - "fn": graph_command.build_graph, - "supports_lang": True, - }, - "symbols": { - "fn": symbols_command.extract_symbols, - "supports_lang": True, - }, - "tests": { - "fn": tests_command.analyze_tests, - "supports_lang": False, - }, -} - -ANALYZER_TIMEOUT_SECONDS = 60 -POLL_INTERVAL_SECONDS = 0.1 -logger = get_logger() - - -def _command_kwargs(command_name: str, lang_filter: str | None) -> dict: - if COMMANDS[command_name]["supports_lang"]: - return {"lang_filter": lang_filter} - - return {} - - -def run_all( - repo: str, - lang_filter: str | None = None, - references: list[str] | None = None, -) -> dict: - if references: - load_reference_documents(references) - - tasks = { - name: (metadata["fn"], _command_kwargs(name, lang_filter)) - for name, metadata in COMMANDS.items() - } - result: dict = {} - executor = ThreadPoolExecutor(max_workers=len(tasks)) - - try: - futures = { - executor.submit(command_fn, repo, **kwargs): name - for name, (command_fn, kwargs) in tasks.items() - } - start_times = {future: monotonic() for future in futures} - pending = set(futures) - - while pending: - done, not_done = wait( - pending, - timeout=POLL_INTERVAL_SECONDS, - return_when=FIRST_COMPLETED, - ) - - for future in done: - name = futures[future] - - try: - result[name] = future.result() - except Exception as exc: - logger.exception("Analyzer %s failed for repo %s", name, repo) - result[name] = {"error": str(exc), "script": name} - - pending.remove(future) - - now = monotonic() - timed_out = { - future - for future in not_done - if now - start_times[future] >= ANALYZER_TIMEOUT_SECONDS - } - - for future in timed_out: - name = futures[future] - - logger.warning( - "Analyzer %s timed out after %ss for repo %s", - name, - ANALYZER_TIMEOUT_SECONDS, - repo, - ) - - result[name] = { - "error": (f"analyzer timed out after {ANALYZER_TIMEOUT_SECONDS}s"), - "script": name, - } - - future.cancel() - pending.remove(future) - - return result - finally: - executor.shutdown(wait=False, cancel_futures=True) - - -def run_many( - repos: list[str], - lang_filter: str | None = None, - references: list[str] | None = None, -) -> dict: - if references: - load_reference_documents(references) - references = None - - if len(repos) == 1: - return run_all(repos[0], lang_filter, references) - - return {repo: run_all(repo, lang_filter, references) for repo in repos} diff --git a/agentskill/lib/update_feedback.py b/agentskill/lib/update_feedback.py deleted file mode 100644 index 878474f..0000000 --- a/agentskill/lib/update_feedback.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Repo-local feedback loading for AGENTS.md update workflows.""" - -import json -from dataclasses import dataclass, field -from pathlib import Path - -from agentskill.lib.agents_document import normalize_section_name - -FEEDBACK_FILENAME = ".agentskill-feedback.json" -SUPPORTED_SECTION_FEEDBACK_KEYS = {"prepend_notes", "pinned_facts"} - - -@dataclass(frozen=True) -class SectionFeedback: - """Explicit feedback for one regenerated section.""" - - prepend_notes: list[str] = field(default_factory=list) - pinned_facts: list[str] = field(default_factory=list) - - -@dataclass(frozen=True) -class UpdateFeedback: - """Normalized update feedback loaded from the repo.""" - - sections: dict[str, SectionFeedback] = field(default_factory=dict) - preserve_sections: list[str] = field(default_factory=list) - - -def empty_feedback() -> UpdateFeedback: - return UpdateFeedback() - - -def _require_object(value: object, label: str) -> dict: - if not isinstance(value, dict): - raise ValueError(f"{label} must be an object") - - return value - - -def _validate_string_list(value: object, label: str) -> list[str]: - if not isinstance(value, list): - raise ValueError(f"{label} must be a list of strings") - - for item in value: - if not isinstance(item, str): - raise ValueError(f"{label} must be a list of strings") - - return value - - -def _dedupe_preserving_order(values: list[str]) -> list[str]: - seen: set[str] = set() - ordered: list[str] = [] - - for value in values: - if value in seen: - continue - - seen.add(value) - ordered.append(value) - - return ordered - - -def validate_feedback(data: object) -> UpdateFeedback: - """Validate and normalize feedback data.""" - root = _require_object(data, "feedback") - sections_data = root.get("sections", {}) - preserve_data = root.get("preserve_sections", []) - - if "sections" in root: - sections_data = _require_object(sections_data, "feedback.sections") - - preserve_sections = ( - _validate_string_list(preserve_data, "feedback.preserve_sections") - if "preserve_sections" in root - else [] - ) - - sections: dict[str, SectionFeedback] = {} - - for raw_name, raw_feedback in sections_data.items(): - if not isinstance(raw_name, str): - raise ValueError("feedback.sections keys must be strings") - - normalized_name = normalize_section_name(raw_name) - - if normalized_name in sections: - raise ValueError( - f"duplicate feedback section after normalization: {raw_name}" - ) - - feedback_obj = _require_object( - raw_feedback, - f"feedback.sections.{raw_name}", - ) - - unknown_keys = sorted( - key for key in feedback_obj if key not in SUPPORTED_SECTION_FEEDBACK_KEYS - ) - - if unknown_keys: - raise ValueError( - f"unsupported feedback keys for section {raw_name}: " - + ", ".join(unknown_keys) - ) - - sections[normalized_name] = SectionFeedback( - prepend_notes=_validate_string_list( - feedback_obj.get("prepend_notes", []), - f"feedback.sections.{raw_name}.prepend_notes", - ), - pinned_facts=_validate_string_list( - feedback_obj.get("pinned_facts", []), - f"feedback.sections.{raw_name}.pinned_facts", - ), - ) - - return UpdateFeedback( - sections=sections, - preserve_sections=_dedupe_preserving_order( - [normalize_section_name(name) for name in preserve_sections] - ), - ) - - -def load_feedback(repo_path: str | Path) -> UpdateFeedback: - """Load optional update feedback from a repository root.""" - root = Path(repo_path) - feedback_path = root / FEEDBACK_FILENAME - - if not feedback_path.exists(): - return empty_feedback() - - try: - raw = json.loads(feedback_path.read_text()) - except json.JSONDecodeError as exc: - raise ValueError(f"invalid feedback JSON: {exc.msg}") from exc - - return validate_feedback(raw) diff --git a/agentskill/lib/update_merge.py b/agentskill/lib/update_merge.py deleted file mode 100644 index 70ccd77..0000000 --- a/agentskill/lib/update_merge.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Merge helpers for incremental AGENTS.md updates.""" - -from dataclasses import dataclass - -from agentskill.lib.agents_document import ( - AgentsDocument, - AgentsSection, - add_or_replace_section, - normalize_section_name, - parse_agents_document, - serialize_agents_document, -) - - -@dataclass(frozen=True) -class MergeResult: - """Structured merge output for future CLI reporting.""" - - text: str - updated_sections: list[str] - preserved_sections: list[str] - added_sections: list[str] - removed_sections: list[str] - forced: bool - - -def _normalize_names(names: list[str] | None) -> set[str]: - if names is None: - return set() - - return {normalize_section_name(name) for name in names} - - -def _normalize_regenerated_sections( - regenerated_sections: dict[str, AgentsSection], -) -> dict[str, AgentsSection]: - normalized_sections: dict[str, AgentsSection] = {} - - for raw_name, section in regenerated_sections.items(): - normalized_name = normalize_section_name(raw_name) - - if normalized_name in normalized_sections: - raise ValueError( - f"duplicate regenerated section after normalization: {raw_name}" - ) - - if section.normalized_name != normalized_name: - raise ValueError( - "regenerated section key does not match section heading: " - f"{raw_name} != {section.heading_text}" - ) - - normalized_sections[normalized_name] = section - - return normalized_sections - - -def _resolve_target_sections( - regenerated_sections: dict[str, AgentsSection], - include_sections: list[str] | None, - exclude_sections: list[str] | None, -) -> list[str]: - included = _normalize_names(include_sections) - excluded = _normalize_names(exclude_sections) - overlap = included & excluded - - if overlap: - names = ", ".join(sorted(overlap)) - raise ValueError(f"section names cannot be both included and excluded: {names}") - - targets = list(regenerated_sections) - - if included: - targets = [name for name in targets if name in included] - - if excluded: - targets = [name for name in targets if name not in excluded] - - return targets - - -def _merge_document( - document: AgentsDocument, - regenerated_sections: dict[str, AgentsSection], - targets: list[str], -) -> tuple[AgentsDocument, list[str], list[str], list[str]]: - existing_names = [section.normalized_name for section in document.sections] - updated_sections: list[str] = [] - added_sections: list[str] = [] - merged = document - - for name in targets: - section = regenerated_sections[name] - - if name in existing_names: - updated_sections.append(name) - else: - added_sections.append(name) - - merged = add_or_replace_section(merged, section) - - preserved_sections = [ - name for name in existing_names if name not in set(updated_sections) - ] - - return merged, updated_sections, preserved_sections, added_sections - - -def order_sections_for_force( - regenerated_sections: dict[str, AgentsSection], - preferred_order: list[str] | None = None, -) -> list[str]: - """Return a stable ordering for force rebuilds.""" - ordered: list[str] = [] - seen: set[str] = set() - - for name in preferred_order or []: - normalized_name = normalize_section_name(name) - - if normalized_name in regenerated_sections and normalized_name not in seen: - ordered.append(normalized_name) - seen.add(normalized_name) - - for name in sorted(regenerated_sections): - if name not in seen: - ordered.append(name) - - return ordered - - -def _build_force_document( - regenerated_sections: dict[str, AgentsSection], - targets: list[str], - *, - preferred_order: list[str] | None = None, - preamble: str = "", -) -> AgentsDocument: - ordered_names = order_sections_for_force( - {name: regenerated_sections[name] for name in targets}, - preferred_order=preferred_order, - ) - return AgentsDocument( - preamble=preamble, - sections=[regenerated_sections[name] for name in ordered_names], - ) - - -def merge_agents_document( - existing_text: str | None, - regenerated_sections: dict[str, AgentsSection], - *, - include_sections: list[str] | None = None, - exclude_sections: list[str] | None = None, - force: bool = False, - document_preamble: str = "", - preferred_order: list[str] | None = None, -) -> MergeResult: - """Merge regenerated sections into an existing AGENTS.md document.""" - normalized_sections = _normalize_regenerated_sections(regenerated_sections) - - targets = _resolve_target_sections( - normalized_sections, - include_sections, - exclude_sections, - ) - - existing_document = parse_agents_document(existing_text or "") - - if existing_text is None and not existing_document.preamble: - existing_document = AgentsDocument( - preamble=document_preamble, - sections=existing_document.sections, - ) - - existing_names = [section.normalized_name for section in existing_document.sections] - - if force: - document = _build_force_document( - normalized_sections, - targets, - preferred_order=preferred_order, - preamble=document_preamble, - ) - - result_names = [section.normalized_name for section in document.sections] - updated_sections = [name for name in result_names if name in existing_names] - added_sections = [name for name in result_names if name not in existing_names] - removed_sections = [name for name in existing_names if name not in result_names] - - return MergeResult( - text=serialize_agents_document(document), - updated_sections=updated_sections, - preserved_sections=[], - added_sections=added_sections, - removed_sections=removed_sections, - forced=True, - ) - - document, updated_sections, preserved_sections, added_sections = _merge_document( - existing_document, - normalized_sections, - targets, - ) - - return MergeResult( - text=serialize_agents_document(document), - updated_sections=updated_sections, - preserved_sections=preserved_sections, - added_sections=added_sections, - removed_sections=[], - forced=False, - ) diff --git a/agentskill/lib/update_runner.py b/agentskill/lib/update_runner.py deleted file mode 100644 index 66b5b88..0000000 --- a/agentskill/lib/update_runner.py +++ /dev/null @@ -1,1184 +0,0 @@ -"""Internal workflow for updating AGENTS.md from current analyzer output.""" - -import re -import sys -from collections import Counter -from pathlib import Path - -if sys.version_info >= (3, 11): - import tomllib -else: - import tomli as tomllib - -from agentskill.common.fs import read_text, validate_repo -from agentskill.lib.agents_document import ( - AgentsSection, - build_section, - normalize_section_name, -) -from agentskill.lib.output import validate_out_path -from agentskill.lib.output_layouts import validate_output_layout -from agentskill.lib.output_profiles import validate_output_profile -from agentskill.lib.profile_rendering import RenderedSectionBody, combine_section_body -from agentskill.lib.runner import run_all -from agentskill.lib.update_feedback import ( - SectionFeedback, - UpdateFeedback, - load_feedback, -) -from agentskill.lib.update_merge import merge_agents_document - -AGENTS_FILENAME = "AGENTS.md" -DOCUMENT_TITLE = "# AGENTS.md\n\n" - -SECTION_ORDER = [ - "overview", - "repository structure", - "service map", - "cross-service boundaries", - "commands and workflows", - "code formatting", - "naming conventions", - "type annotations", - "imports", - "error handling", - "comments and docstrings", - "testing", - "git", - "dependencies and tooling", - "red lines", -] - -SECTION_HEADINGS = { - "overview": "1. Overview", - "repository structure": "2. Repository Structure", - "service map": "3. Service Map", - "cross-service boundaries": "4. Cross-Service Boundaries", - "commands and workflows": "5. Commands and Workflows", - "code formatting": "6. Code Formatting", - "naming conventions": "7. Naming Conventions", - "type annotations": "8. Type Annotations", - "imports": "9. Imports", - "error handling": "10. Error Handling", - "comments and docstrings": "11. Comments and Docstrings", - "testing": "12. Testing", - "git": "13. Git", - "dependencies and tooling": "14. Dependencies and Tooling", - "red lines": "15. Red Lines", -} - - -def _format_languages(scan: dict) -> str: - by_language = scan.get("summary", {}).get("by_language", {}) - languages = sorted(by_language) - - if not languages: - return "No primary language could be determined from the repository scan." - - if len(languages) == 1: - return languages[0] - - return ", ".join(languages[:-1]) + f", and {languages[-1]}" - - -def _code_block(snippet: str, lang: str = "python") -> str: - return f"```{lang}\n{snippet.rstrip()}\n```" - - -def _read_pyproject(repo: Path) -> dict: - pyproject = repo / "pyproject.toml" - - if not pyproject.exists(): - return {} - - try: - with pyproject.open("rb") as file_obj: - return tomllib.load(file_obj) - except Exception: - return {} - - -def _readme_summary(repo: Path) -> str | None: - readme = repo / "README.md" - content = read_text(readme, None) - - if not content: - return None - - paragraphs = [chunk.strip() for chunk in content.split("\n\n")] - - for paragraph in paragraphs: - if not paragraph or paragraph.startswith("#") or paragraph.startswith("---"): - continue - - if paragraph.startswith("```") or paragraph.startswith("|"): - continue - - return " ".join(paragraph.splitlines()).strip() - - return None - - -def _top_level_layout(scan: dict) -> list[str]: - tree = scan.get("tree", []) - grouped: dict[str, list[str]] = {} - - for entry in tree: - path = entry.get("path", "") - - if not path: - continue - - head = path.split("/", 1)[0] - grouped.setdefault(head, []).append(path) - - lines: list[str] = [] - - for name in sorted(grouped): - suffix = "/" if any("/" in path for path in grouped[name]) else "" - kind = "test files" if name == "tests" else "source files" - lines.append(f"{name}{suffix} # {kind} ({len(grouped[name])} files)") - - return lines - - -def _python_commands(config: dict, tests: dict) -> list[str]: - commands = ["pip install -e ."] - python_tests = tests.get("python", {}) - run_command = python_tests.get("run_command") - - if run_command and run_command != "unknown": - commands.append(run_command) - - python_config = config.get("python", {}) - linter = python_config.get("linter", {}).get("name") - - if linter == "ruff": - commands.extend(["ruff format .", "ruff check ."]) - - type_checker = python_config.get("type_checker", {}).get("name") - - if type_checker == "mypy": - commands.append("mypy") - - return commands - - -def _render_overview(repo: Path, analysis: dict) -> RenderedSectionBody: - scan = analysis.get("scan", {}) - graph = analysis.get("graph", {}) - boundaries = graph.get("monorepo_boundaries", {}) - languages = _format_languages(scan) - architecture = "monorepo" if boundaries.get("detected") else "single repository" - - readme_summary = _readme_summary(repo) - pyproject = _read_pyproject(repo) - project = pyproject.get("project", {}) - scripts = project.get("scripts", {}) - cli_names = ", ".join(sorted(scripts)) if isinstance(scripts, dict) else "" - - parts = [] - - if readme_summary: - parts.append(readme_summary) - else: - parts.append( - f"{repo.name} is a {architecture} codebase analyzed by agentskill." - ) - - if cli_names: - parts.append(f"The packaged CLI surface is exposed through `{cli_names}`.") - - parts.append( - f"The primary language set detected here is {languages}, and the codebase is organized as a {architecture} with analyzer-driven markdown generation." - ) - - core = " ".join(parts) + "\n" - expanded = "" - - if cli_names and readme_summary: - expanded = f"The published console scripts include `{cli_names}`.\n" - - return RenderedSectionBody(core=core, expanded=expanded) - - -def _render_repository_structure(analysis: dict) -> RenderedSectionBody: - scan = analysis.get("scan", {}) - lines = _top_level_layout(scan) - body = [ - "```text", - *lines, - "```", - "", - ] - - line_text = "\n".join(lines) - - core_bullets: list[str] = [] - expanded_bullets: list[str] = [] - - if "tests/" in line_text: - core_bullets.append( - "- Keep tests under `tests/`; this repo separates tests from source." - ) - - if "scripts/" in line_text: - core_bullets.append( - "- Keep direct-execution wrappers under `scripts/`; use the packaged runtime for reusable logic." - ) - - if "examples/" in line_text: - core_bullets.append( - "- Keep example or fixture repositories under `examples/`; do not mix them into runtime packages." - ) - - source_roots = [ - line.split(" #", 1)[0] for line in lines if not line.startswith("tests") - ] - - if source_roots: - core_bullets.append( - f"- Keep new source files under existing roots such as `{source_roots[0]}`." - ) - - if len(source_roots) > 1: - expanded_bullets.append( - f"- Additional source roots detected: {', '.join(f'`{r}`' for r in source_roots[1:])}." - ) - - core = "\n".join(body + core_bullets) + "\n" - expanded = "" - - if expanded_bullets: - expanded = "\n".join(expanded_bullets) + "\n" - - return RenderedSectionBody(core=core, expanded=expanded) - - -def _render_service_map(analysis: dict) -> RenderedSectionBody | None: - boundaries = analysis.get("graph", {}).get("monorepo_boundaries", {}) - services = boundaries.get("services", []) - - if not boundaries.get("detected") or not services: - return None - - core_lines: list[str] = [] - expanded_lines: list[str] = [] - - for service in services: - core_lines.append(f"- `{service}`: service root at `{service}`") - expanded_lines.append(f" - Service root: `{service}`") - - core = "\n".join(core_lines) + "\n" - expanded = "" - - if expanded_lines: - expanded = "\n".join(expanded_lines) + "\n" - - return RenderedSectionBody(core=core, expanded=expanded) - - -def _render_cross_service_boundaries(analysis: dict) -> RenderedSectionBody | None: - boundaries = analysis.get("graph", {}).get("monorepo_boundaries", {}) - - if not boundaries.get("detected"): - return None - - imports = boundaries.get("cross_service_imports", []) - - if imports: - core = ( - "- Cross-service imports were detected in the dependency graph.\n" - "- Review shared contracts before changing any service boundary.\n" - ) - - expanded = ( - "- Cross-service imports compromise service isolation; " - "introduce a shared contract layer before adding new cross-service dependencies.\n" - ) - else: - core = ( - "- No cross-service imports were detected in the current graph analysis.\n" - "- Preserve service boundaries unless a shared contract layer is introduced.\n" - ) - - expanded = "" - - return RenderedSectionBody(core=core, expanded=expanded) - - -def _render_commands_and_workflows(analysis: dict) -> RenderedSectionBody: - commands = _python_commands( - analysis.get("config", {}), - analysis.get("tests", {}), - ) - - core = ( - "```bash\n" - + "\n".join(commands) - + "\n```\n\n" - + "- Use the editable install plus the full `ruff`/`mypy`/`pytest` stack as the canonical local verification path.\n" - ) - - expanded = "- Treat the installed CLI as the primary runtime surface; keep direct wrapper scripts as thin operator entrypoints when they exist.\n" - - return RenderedSectionBody(core=core, expanded=expanded) - - -def _render_code_formatting(repo: Path, analysis: dict) -> RenderedSectionBody: - python_metrics = analysis.get("measure", {}).get("python", {}) - - if not python_metrics: - return RenderedSectionBody( - core="No formatting metrics were extracted from the current analysis run.\n" - ) - - indentation = python_metrics.get("indentation", {}) - line_length = python_metrics.get("line_length", {}) - - core = ( - "### Python\n\n" - f"- Indent with `{indentation.get('size', 0)}` {indentation.get('unit', 'unknown')}; Python files in the scan do not rely on tab-indented blocks.\n" - f"- Keep ordinary lines around the measured p95 of `{line_length.get('p95', 0)}` and preserve the repo's one-blank-line import-to-constant / two-blank-lines top-level rhythm.\n" - f"- Leave trailing whitespace stripped and keep a final trailing newline in generated files.\n" - f"- Follow hanging-indented multiline calls and literals rather than backslash continuations.\n" - ) - - expanded = "" - multiline = _multiline_call_snippet(repo, analysis) - - if multiline: - expanded = "\n" + _code_block(multiline) + "\n" - - return RenderedSectionBody(core=core, expanded=expanded) - - -def _render_naming_conventions(repo: Path, analysis: dict) -> RenderedSectionBody: - symbols = analysis.get("symbols", {}).get("python", {}) - function_patterns = ", ".join( - sorted(symbols.get("functions", {}).get("patterns", {})) - ) - - class_patterns = ", ".join(sorted(symbols.get("classes", {}).get("patterns", {}))) - constant_patterns = ", ".join( - sorted(symbols.get("constants", {}).get("patterns", {})) - ) - - function_name = _first_python_name(repo, analysis, r"^def ([a-zA-Z0-9_]+)\(") - class_name = _first_python_name(repo, analysis, r"^class ([A-Za-z0-9_]+)") - - constant_name = _first_python_name(repo, analysis, r"^([A-Z][A-Z0-9_]+)\s*=") - test_file = next( - ( - entry.get("path", "") - for entry in analysis.get("scan", {}).get("tree", []) - if entry.get("path", "").startswith("tests/test_") - ), - "", - ) - - core = ( - "### Python\n\n" - f"- Keep public helpers and command functions in snake_case; representative names include `{function_name or 'analyze'}`.\n" - f"- Use PascalCase for classes when they appear; representative names follow patterns like `{class_name or 'ReferenceDocument'}`.\n" - f"- Keep module constants in SCREAMING_SNAKE_CASE; representative names include `{constant_name or 'GIT_TIMEOUT'}`.\n" - f"- Name test modules as `test_.py`; representative paths look like `{test_file or 'tests/test_cli.py'}`.\n" - ) - - expanded = "" - constant_snippet = _constant_snippet(repo, analysis) - - if constant_snippet: - expanded_bullets = ( - f"- Observed naming patterns: functions `{function_patterns or 'unknown'}`, " - f"classes `{class_patterns or 'PascalCase'}`, constants `{constant_patterns or 'unknown'}`.\n" - ) - - expanded = expanded_bullets + "\n" + _code_block(constant_snippet) + "\n" - - return RenderedSectionBody(core=core, expanded=expanded) - - -def _python_source_paths(scan: dict) -> list[str]: - return [ - entry.get("path", "") - for entry in scan.get("tree", []) - if entry.get("language") == "python" - ] - - -def _python_read_order(scan: dict) -> list[str]: - tree_paths = set(_python_source_paths(scan)) - ordered = [ - path for path in scan.get("read_order", []) if path in tree_paths and path - ] - - for path in sorted(tree_paths): - if path not in ordered: - ordered.append(path) - - return ordered - - -def _first_python_line(repo: Path, analysis: dict, pattern: str) -> str | None: - needle = re.compile(pattern) - - for rel_path in _python_read_order(analysis.get("scan", {})): - content = read_text(repo / rel_path) - - for line in content.splitlines(): - if needle.search(line): - return line.strip() - - return None - - -def _first_python_name(repo: Path, analysis: dict, pattern: str) -> str | None: - needle = re.compile(pattern) - - for rel_path in _python_read_order(analysis.get("scan", {})): - content = read_text(repo / rel_path) - - for line in content.splitlines(): - match = needle.search(line.strip()) - - if match is not None: - return match.group(1) - - return None - - -def _module_docstring_snippet(repo: Path, analysis: dict) -> str | None: - def matcher(lines: list[str]) -> str | None: - for index, line in enumerate(lines[:10]): - if line.strip().startswith('"""'): - end = min(len(lines), index + 3) - return _trim_snippet(lines[index:end]) - - return None - - return _first_python_snippet(repo, analysis, matcher) - - -def _inline_comment_snippet(repo: Path, analysis: dict) -> str | None: - def matcher(lines: list[str]) -> str | None: - for index, line in enumerate(lines): - if " #" not in line or line.lstrip().startswith("#"): - continue - - return _function_snippet(lines, index) - - return None - - return _first_python_snippet(repo, analysis, matcher) - - -def _multiline_call_snippet(repo: Path, analysis: dict) -> str | None: - def matcher(lines: list[str]) -> str | None: - for index, line in enumerate(lines): - if not line.rstrip().endswith("("): - continue - - snippet = _function_snippet(lines, index) - - if "\n" in snippet and ")" in snippet: - return snippet - - return None - - return _first_python_snippet(repo, analysis, matcher) - - -def _typed_signature_snippet(repo: Path, analysis: dict) -> str | None: - def matcher(lines: list[str]) -> str | None: - for index, line in enumerate(lines): - stripped = line.strip() - - if stripped.startswith("def ") and "->" in stripped: - return _function_snippet(lines, index) - - return None - - return _first_python_snippet(repo, analysis, matcher) - - -def _class_snippet(repo: Path, analysis: dict) -> str | None: - def matcher(lines: list[str]) -> str | None: - for index, line in enumerate(lines): - if line.strip().startswith("class "): - return _trim_snippet(lines[index : min(len(lines), index + 6)]) - - return None - - return _first_python_snippet(repo, analysis, matcher) - - -def _constant_snippet(repo: Path, analysis: dict) -> str | None: - def matcher(lines: list[str]) -> str | None: - block: list[str] = [] - - for line in lines: - stripped = line.strip() - - if not stripped or stripped.startswith("#"): - if block: - break - continue - - if re.match(r"^[A-Z][A-Z0-9_]+\s*=", stripped): - block.append(stripped) - continue - - if block: - break - - return "\n".join(block) if block else None - - return _first_python_snippet(repo, analysis, matcher) - - -def _representative_test_snippet(repo: Path, analysis: dict) -> str | None: - tests = analysis.get("tests", {}).get("python", {}) - rel_path = tests.get("representative_test") - - if not isinstance(rel_path, str) or not rel_path: - return None - - content = read_text(repo / rel_path) - - if not content: - return None - - lines = content.splitlines() - start = None - - for index, line in enumerate(lines): - if line.strip().startswith("def test_"): - start = index - break - - if start is None: - return _trim_snippet(lines[: min(len(lines), 12)]) - - end = min(len(lines), start + 8) - return _trim_snippet(lines[start:end]) - - -def _render_type_annotations(repo: Path, analysis: dict) -> RenderedSectionBody: - scan = analysis.get("scan", {}) - paths = _python_source_paths(scan) - annotated = 0 - total_defs = 0 - - for rel_path in paths: - content = read_text(repo / rel_path) - - for line in content.splitlines(): - stripped = line.strip() - - if not stripped.startswith("def "): - continue - - total_defs += 1 - - if "->" in stripped or ":" in stripped.split("(", 1)[1]: - annotated += 1 - - config = analysis.get("config", {}).get("python", {}) - type_checker = config.get("type_checker", {}).get("name") - - core = ( - "### Python\n\n" - "- Prefer built-in generics like `list[str]` and union syntax like `str | None` instead of legacy `typing.List` or `Optional` spellings.\n" - f"- Treat `{type_checker or 'the configured type checker'}` as part of the normal contract when it is present in repo config.\n" - ) - - expanded = ( - f"- Annotate most public and internal helpers directly in the function signature; " - f"the current scan found `{annotated}` annotated definitions out of `{total_defs}` observed `def` lines.\n" - ) - - typed_signature = _typed_signature_snippet(repo, analysis) - - if typed_signature: - expanded += "\n" + _code_block(typed_signature) + "\n" - - return RenderedSectionBody(core=core, expanded=expanded) - - -def _first_import_block(repo: Path, analysis: dict) -> str: - scan = analysis.get("scan", {}) - - for rel_path in scan.get("read_order", []): - content = read_text(repo / rel_path) - lines: list[str] = [] - - for line in content.splitlines(): - stripped = line.strip() - - if stripped.startswith("import ") or stripped.startswith("from "): - lines.append(line) - continue - - if lines and not stripped: - lines.append(line) - continue - - if lines: - break - - if lines: - return "\n".join(lines).rstrip() - - return "" - - -def _render_imports(repo: Path, analysis: dict) -> RenderedSectionBody: - block = _first_import_block(repo, analysis) - - if not block: - return RenderedSectionBody( - core="No representative import block was found in the scanned files.\n" - ) - - core = ( - "### Python\n\n" - "- Keep imports one-per-line and separate major groups with a blank line.\n" - "- In runtime modules, stdlib imports come first and local package imports follow.\n" - "- In tests, local test helpers may appear before packaged runtime imports when that matches the file's setup style.\n" - ) - - expanded = "\n" + _code_block(block) + "\n" - - return RenderedSectionBody(core=core, expanded=expanded) - - -def _indentation(line: str) -> int: - return len(line) - len(line.lstrip(" ")) - - -def _trim_snippet(lines: list[str]) -> str: - start = 0 - end = len(lines) - - while start < end and not lines[start].strip(): - start += 1 - - while end > start and not lines[end - 1].strip(): - end -= 1 - - return "\n".join(lines[start:end]).rstrip() - - -def _function_snippet(lines: list[str], anchor: int) -> str: - start = anchor - - while start > 0: - candidate = lines[start].lstrip() - - if candidate.startswith("def "): - break - - start -= 1 - - base_indent = _indentation(lines[start]) if lines[start].strip() else 0 - end = len(lines) - - for index in range(start + 1, len(lines)): - stripped = lines[index].strip() - - if not stripped: - continue - - if _indentation(lines[index]) <= base_indent and not stripped.startswith("#"): - end = index - break - - return _trim_snippet(lines[start:end]) - - -def _try_except_snippet(lines: list[str], anchor: int) -> str: - start = anchor - - while start > 0: - if lines[start].lstrip().startswith("try:"): - break - - start -= 1 - - if not lines[start].lstrip().startswith("try:"): - start = anchor - - block_indent = _indentation(lines[start]) if lines[start].strip() else 0 - end = len(lines) - - for index in range(start + 1, len(lines)): - stripped = lines[index].strip() - - if not stripped: - continue - - if _indentation(lines[index]) <= block_indent and not stripped.startswith( - ("except", "finally", "else:") - ): - end = index - break - - return _trim_snippet(lines[start:end]) - - -def _first_python_snippet( - repo: Path, - analysis: dict, - matcher, -) -> str | None: - scan = analysis.get("scan", {}) - - for rel_path in _python_read_order(scan): - content = read_text(repo / rel_path) - - if not content: - continue - - lines = content.splitlines() - snippet = matcher(lines) - - if snippet: - return snippet - - return None - - -def _value_error_snippet(repo: Path, analysis: dict) -> str | None: - def matcher(lines: list[str]) -> str | None: - for index, line in enumerate(lines): - if "raise ValueError(" in line: - return _function_snippet(lines, index) - - return None - - return _first_python_snippet(repo, analysis, matcher) - - -def _error_payload_snippet(repo: Path, analysis: dict) -> str | None: - def matcher(lines: list[str]) -> str | None: - for index, line in enumerate(lines): - if 'return {"error":' in line and '"script"' in line: - return _function_snippet(lines, index) - - return None - - return _first_python_snippet(repo, analysis, matcher) - - -def _logged_exception_snippet(repo: Path, analysis: dict) -> str | None: - def matcher(lines: list[str]) -> str | None: - for index, line in enumerate(lines): - if "logger.exception(" in line or ( - "print(" in line and "file=sys.stderr" in line - ): - return _try_except_snippet(lines, index) - - return None - - return _first_python_snippet(repo, analysis, matcher) - - -def _fallback_helper_snippet(repo: Path, analysis: dict) -> str | None: - def matcher(lines: list[str]) -> str | None: - for index, line in enumerate(lines): - stripped = line.strip() - - if stripped not in {'return ""', "return 0"}: - continue - - if index == 0 or lines[index - 1].strip() != "except Exception:": - continue - - return _function_snippet(lines, index) - - return None - - return _first_python_snippet(repo, analysis, matcher) - - -def _render_error_handling(repo: Path, analysis: dict) -> RenderedSectionBody: - value_error = _value_error_snippet(repo, analysis) - error_payload = _error_payload_snippet(repo, analysis) - logged_exception = _logged_exception_snippet(repo, analysis) - fallback_helper = _fallback_helper_snippet(repo, analysis) - - core_bullets: list[str] = [] - expanded_bullets: list[str] = [] - snippets: list[str] = [] - - if value_error: - core_bullets.append( - "- Low-level validators raise `ValueError` with specific message text for invalid caller input." - ) - - snippets.append("```python\n" + value_error + "\n```") - - if error_payload: - core_bullets.append( - '- Analyzer boundaries convert validation failures into exact `{"error": ..., "script": ...}` payloads.' - ) - - snippets.append("```python\n" + error_payload + "\n```") - - if logged_exception: - core_bullets.append( - "- Shared CLI wrappers catch broad exceptions and return non-zero status instead of letting failures escape unshaped." - ) - - snippets.append("```python\n" + logged_exception + "\n```") - - if fallback_helper: - core_bullets.append( - '- Best-effort file helpers swallow unreadable-file exceptions and fall back to `""` or `0` so scans can continue.' - ) - - snippets.append("```python\n" + fallback_helper + "\n```") - - if not core_bullets: - return RenderedSectionBody( - core="### Python\n\n- No stable error-handling pattern could be extracted from the scanned Python files.\n" - ) - - core_bullets.append( - "- Match the existing boundary between raised validation errors and user-facing error payloads instead of introducing a new exception contract." - ) - - expanded_bullets.append( - "- Shared CLI wrappers log or print diagnostics before converting failures into non-zero status." - ) - - core = "### Python\n\n" + "\n".join(core_bullets) + "\n" - expanded = "" - - if snippets or expanded_bullets: - expanded_text = "\n".join(expanded_bullets) + "\n\n" + "\n\n".join(snippets) - expanded = "\n" + expanded_text + "\n" - - return RenderedSectionBody(core=core, expanded=expanded) - - -def _render_comments_and_docstrings(repo: Path, analysis: dict) -> RenderedSectionBody: - scan = analysis.get("scan", {}) - docstrings = 0 - comments = 0 - - for rel_path in scan.get("read_order", []): - content = read_text(repo / rel_path) - docstrings += content.count('"""') - - for line in content.splitlines(): - if line.strip().startswith("#"): - comments += 1 - - core = ( - "### Python\n\n" - "- Prefer short, declarative docstrings and brief targeted inline comments when the code would otherwise be ambiguous.\n" - "- Keep inline comments sparse; use them to clarify a non-obvious detail rather than narrating obvious code.\n" - ) - - expanded = ( - f"- Module docstrings are common in runtime files; the scan saw `{docstrings}` triple-quoted docstring markers across representative Python files.\n" - f"- The scan saw `{comments}` comment lines in the representative pass.\n" - ) - - module_docstring = _module_docstring_snippet(repo, analysis) - inline_comment = _inline_comment_snippet(repo, analysis) - - if module_docstring: - expanded += "\n" + _code_block(module_docstring) + "\n" - - if inline_comment: - expanded += "\n" + _code_block(inline_comment) + "\n" - - return RenderedSectionBody(core=core, expanded=expanded) - - -def _render_testing(repo: Path, analysis: dict) -> RenderedSectionBody: - python_tests = analysis.get("tests", {}).get("python", {}) - coverage = python_tests.get("coverage_shape", {}) - fixtures = python_tests.get("fixtures", {}) - test_snippet = _representative_test_snippet(repo, analysis) - - core = ( - "### Python\n\n" - f"- Use `{python_tests.get('framework', 'unknown')}` as the primary Python test framework and `{python_tests.get('run_command', 'unknown')}` as the full-suite command.\n" - f"- Keep Python tests under the detected pattern `{python_tests.get('naming', {}).get('file_pattern', 'unknown')}` and follow function names like `{python_tests.get('naming', {}).get('function_pattern', 'test_')}`.\n" - ) - - expanded = ( - f"- Reuse shared test bootstrap from `{', '.join(fixtures.get('conftest_locations', [])) or 'tests/conftest.py'}` when present.\n" - f"- The current source-to-test mapping leaves `{len(coverage.get('untested_source_files', []))}` Python source files without a matched test file, so new source files should usually arrive with an adjacent or mirrored test.\n" - ) - - if test_snippet: - expanded += "\n" + _code_block(test_snippet) + "\n" - - return RenderedSectionBody(core=core, expanded=expanded) - - -def _render_git(analysis: dict) -> RenderedSectionBody: - git = analysis.get("git", {}) - - if "error" in git: - return RenderedSectionBody( - core=f"- Git analysis unavailable: `{git['error']}`.\n" - ) - - commits = git.get("commits", {}) - prefixes = commits.get("prefixes", {}) - prefix_names = ", ".join(sorted(prefixes)) or "unknown" - merge_strategy = git.get("merge_strategy", {}).get("detected", "unknown") - - core = ( - f"- Commit subjects follow conventional prefixes such as `{prefix_names}`.\n" - f"- The observed merge strategy is `{merge_strategy}`.\n" - ) - - examples = [ - f"`{name}:` for commits like `{info.get('example', '')}`" - for name, info in list(prefixes.items())[:5] - ] - - expanded = "" - - if examples: - expanded = f"- Representative commit examples include {', '.join(examples)}.\n" - - branch_example = git.get("branches", {}).get("naming_example") - - if branch_example: - core += f"- Branch names use slash-separated prefixes with examples like `{branch_example}`.\n" - - return RenderedSectionBody(core=core, expanded=expanded) - - -def _render_dependencies_and_tooling(repo: Path, analysis: dict) -> RenderedSectionBody: - config = analysis.get("config", {}) - tools: list[str] = [] - - for lang_data in config.values(): - if not isinstance(lang_data, dict): - continue - - for tool_type in ("formatter", "linter", "type_checker"): - tool_info = lang_data.get(tool_type) - - if isinstance(tool_info, dict) and tool_info.get("name"): - tools.append(tool_info["name"]) - - counts = Counter(tools) - tool_list = ", ".join(sorted(counts)) or "none detected" - pyproject = _read_pyproject(repo) - project = pyproject.get("project", {}) - requires_python = project.get("requires-python", "unknown") - package_name = project.get("name", repo.name) - scripts = project.get("scripts", {}) - script_names = ", ".join(sorted(scripts)) if isinstance(scripts, dict) else "" - - core = ( - "### Python\n\n" - f"- Package metadata lives in `pyproject.toml`; the current project name is `{package_name}` and the declared Python floor is `{requires_python}`.\n" - f"- Tooling detected from repo config includes `{tool_list}`.\n" - ) - - expanded = "" - - if script_names: - expanded = f"- Published console scripts include `{script_names}`.\n" - - return RenderedSectionBody(core=core, expanded=expanded) - - -def _render_red_lines(repo: Path, analysis: dict) -> RenderedSectionBody: - scan = analysis.get("scan", {}) - roots = ", ".join( - sorted({path.split("/", 1)[0] for path in scan.get("read_order", [])}) - ) - - commands = _python_commands( - analysis.get("config", {}), - analysis.get("tests", {}), - ) - - package_name = next( - ( - root - for root in sorted( - {path.split("/", 1)[0] for path in scan.get("read_order", [])} - ) - if root not in {"tests", "scripts", "examples"} - ), - "src", - ) - - core = ( - f"- Do not invent new top-level layout patterns when the scan already shows established roots such as `{roots or 'the detected source tree'}`.\n" - f"- Do not move reusable runtime logic out of `{package_name}` into thin wrapper locations like `scripts/`.\n" - "- Do not colocate new tests beside source modules when the repo already maintains a separate `tests/` tree.\n" - "- Do not replace built-in generics and `| None` unions with older `typing.List` / `Optional` spellings in annotated Python code.\n" - "- Do not switch import grouping to a flat unsplit block when runtime modules already separate stdlib and local imports.\n" - "- Do not replace the documented verification stack with ad hoc commands; keep local checks aligned with `" - + ", ".join(commands) - + "`.\n" - ) - - expanded = ( - "- Do not introduce broad formatting drift such as tabs in Python, missing trailing newlines, or backslash-heavy continuation style.\n" - "- Do not convert structured error payload boundaries into uncaught CLI exceptions when the repo already normalizes them at command boundaries.\n" - "- Do not treat example or fixture repositories as runtime code when `examples/` is present as a separate root.\n" - "- Do not assume missing analyzer signals imply permission to rewrite local conventions.\n" - ) - - return RenderedSectionBody(core=core, expanded=expanded) - - -def _apply_section_feedback(body: str, feedback: SectionFeedback | None) -> str: - if feedback is None: - return body - - parts: list[str] = [] - - if feedback.prepend_notes: - notes = "\n".join(f"- {note}" for note in feedback.prepend_notes) - parts.append("Maintainer notes from `.agentskill-feedback.json`:\n" + notes) - - if feedback.pinned_facts: - facts = "\n".join(f"- {fact}" for fact in feedback.pinned_facts) - parts.append("Pinned facts from `.agentskill-feedback.json`:\n" + facts) - - parts.append(body.rstrip("\n")) - return "\n\n".join(parts) + "\n" - - -def render_agents_sections( - repo: Path, - analysis: dict, - feedback: UpdateFeedback | None = None, - profile: str = "concise", -) -> dict[str, AgentsSection]: - rendered: dict[str, RenderedSectionBody | None] = { - "overview": _render_overview(repo, analysis), - "repository structure": _render_repository_structure(analysis), - "service map": _render_service_map(analysis), - "cross-service boundaries": _render_cross_service_boundaries(analysis), - "commands and workflows": _render_commands_and_workflows(analysis), - "code formatting": _render_code_formatting(repo, analysis), - "naming conventions": _render_naming_conventions(repo, analysis), - "type annotations": _render_type_annotations(repo, analysis), - "imports": _render_imports(repo, analysis), - "error handling": _render_error_handling(repo, analysis), - "comments and docstrings": _render_comments_and_docstrings(repo, analysis), - "testing": _render_testing(repo, analysis), - "git": _render_git(analysis), - "dependencies and tooling": _render_dependencies_and_tooling(repo, analysis), - "red lines": _render_red_lines(repo, analysis), - } - - sections: dict[str, AgentsSection] = {} - - for name in SECTION_ORDER: - body = rendered.get(name) - - if body is None: - continue - - rendered_text = combine_section_body(profile, body) - section_feedback = None if feedback is None else feedback.sections.get(name) - sections[name] = build_section( - SECTION_HEADINGS[name], - _apply_section_feedback(rendered_text, section_feedback), - heading_level=2, - ) - - return sections - - -def _resolve_update_path(repo: Path, out: str | None) -> Path: - if out is None: - return repo / AGENTS_FILENAME - - return validate_out_path(out) - - -def _validate_requested_sections( - include_sections: list[str] | None, - exclude_sections: list[str] | None, - supported_sections: dict[str, AgentsSection], -) -> None: - requested = { - *[normalize_section_name(name) for name in include_sections or []], - *[normalize_section_name(name) for name in exclude_sections or []], - } - unsupported = sorted(name for name in requested if name not in supported_sections) - - if unsupported: - names = ", ".join(unsupported) - raise ValueError(f"unsupported or unavailable sections: {names}") - - -def update_agents( - repo: str, - *, - include_sections: list[str] | None = None, - exclude_sections: list[str] | None = None, - force: bool = False, - out: str | None = None, - profile: str = "concise", - layout: str = "single", -) -> int: - """Update or create AGENTS.md for a repository.""" - try: - profile = validate_output_profile(profile) - layout = validate_output_layout(layout) - - if layout == "split": - raise NotImplementedError( - "update with layout 'split' is not implemented yet" - ) - - if layout == "multifile": - raise NotImplementedError( - "update with layout 'multifile' is not implemented yet" - ) - - repo_path = validate_repo(repo) - analysis = run_all(str(repo_path)) - feedback = load_feedback(repo_path) - - sections = render_agents_sections( - repo_path, analysis, feedback, profile=profile - ) - - preserve_sections = [] if force else feedback.preserve_sections - effective_excludes = [*(exclude_sections or []), *preserve_sections] - _validate_requested_sections(include_sections, effective_excludes, sections) - target_path = _resolve_update_path(repo_path, out) - existing_path = repo_path / AGENTS_FILENAME - - existing_text = ( - read_text(existing_path, None) if existing_path.exists() else None - ) - - merged = merge_agents_document( - existing_text, - sections, - include_sections=include_sections, - exclude_sections=effective_excludes, - force=force, - document_preamble=DOCUMENT_TITLE, - preferred_order=SECTION_ORDER, - ) - - target_path.parent.mkdir(parents=True, exist_ok=True) - target_path.write_text(merged.text) - except Exception as exc: - print(f"Update failed for repo {repo}: {exc}", file=sys.stderr) - return 1 - - return 0 diff --git a/agentskill/main.py b/agentskill/main.py deleted file mode 100644 index b1ccb49..0000000 --- a/agentskill/main.py +++ /dev/null @@ -1,278 +0,0 @@ -"""Packaged CLI entrypoint for agentskill.""" - -import argparse -import sys - -from agentskill.lib.generate_runner import generate_agents -from agentskill.lib.logging_utils import configure_logging -from agentskill.lib.output import run_and_output, write_output -from agentskill.lib.output_layouts import ( - DEFAULT_OUTPUT_LAYOUT, - validate_output_layout, -) -from agentskill.lib.output_profiles import ( - DEFAULT_OUTPUT_PROFILE, - validate_output_profile, -) -from agentskill.lib.runner import COMMANDS, run_many -from agentskill.lib.update_runner import update_agents - - -def cmd_analyze(args: argparse.Namespace) -> int: - try: - result = run_many( - args.repos, - getattr(args, "lang", None), - getattr(args, "reference", None), - ) - except Exception as exc: - print(str(exc), file=sys.stderr) - return 1 - - write_output( - result, - args.pretty, - getattr(args, "out", None), - schema_mode="analyze", - ) - - return 0 - - -def _single_script_cmd(command_name: str, args: argparse.Namespace) -> int: - metadata = COMMANDS[command_name] - extra_kwargs = {} - - if metadata["supports_lang"]: - extra_kwargs["lang_filter"] = getattr(args, "lang", None) - - return run_and_output( - metadata["fn"], - repo=args.repo, - pretty=args.pretty, - out=getattr(args, "out", None), - script_name=command_name, - extra_kwargs=extra_kwargs, - ) - - -def cmd_update(args: argparse.Namespace) -> int: - if getattr(args, "pretty", False): - print("update does not support --pretty", file=sys.stderr) - return 1 - - try: - profile = validate_output_profile( - getattr(args, "profile", DEFAULT_OUTPUT_PROFILE) - ) - except ValueError as exc: - print(str(exc), file=sys.stderr) - return 1 - - try: - layout = validate_output_layout(getattr(args, "layout", DEFAULT_OUTPUT_LAYOUT)) - except ValueError as exc: - print(str(exc), file=sys.stderr) - return 1 - - return update_agents( - args.repo, - include_sections=getattr(args, "section", None), - exclude_sections=getattr(args, "exclude_section", None), - force=args.force, - out=getattr(args, "out", None), - profile=profile, - layout=layout, - ) - - -def cmd_generate(args: argparse.Namespace) -> int: - if getattr(args, "pretty", False): - print("generate does not support --pretty", file=sys.stderr) - return 1 - - try: - profile = validate_output_profile( - getattr(args, "profile", DEFAULT_OUTPUT_PROFILE) - ) - except ValueError as exc: - print(str(exc), file=sys.stderr) - return 1 - - try: - layout = validate_output_layout(getattr(args, "layout", DEFAULT_OUTPUT_LAYOUT)) - except ValueError as exc: - print(str(exc), file=sys.stderr) - return 1 - - return generate_agents( - args.repo, - out=getattr(args, "out", None), - references=getattr(args, "reference", None), - interactive=getattr(args, "interactive", False), - profile=profile, - layout=layout, - ) - - -def main(argv: list[str] | None = None) -> int: - configure_logging() - parser = argparse.ArgumentParser( - prog="agentskill", - description="agentskill CLI", - ) - - parser.add_argument( - "--pretty", action="store_true", help="Pretty-print JSON output" - ) - - parser.add_argument( - "--out", metavar="FILE", help="Write output to file instead of stdout" - ) - - sub = parser.add_subparsers(dest="command", required=True) - - p_analyze = sub.add_parser("analyze", help="Run all scripts and merge output") - p_analyze.add_argument( - "repos", nargs="+", metavar="repo", help="Path(s) to repository" - ) - - p_analyze.add_argument( - "--lang", help="Filter to a single language where applicable" - ) - - p_analyze.add_argument( - "--reference", - action="append", - help="Reference repository path or URL; may be repeated", - ) - - p_scan = sub.add_parser("scan", help="Directory tree + file inventory") - p_scan.add_argument("repo", help="Path to repository") - p_scan.add_argument("--lang", help="Filter to a single language") - - p_measure = sub.add_parser("measure", help="Exact formatting metrics") - p_measure.add_argument("repo", help="Path to repository") - p_measure.add_argument("--lang", help="Filter to a single language") - - p_config = sub.add_parser("config", help="Formatter/linter detection and config") - p_config.add_argument("repo", help="Path to repository") - - p_git = sub.add_parser("git", help="Commit log and branch analysis") - p_git.add_argument("repo", help="Path to repository") - - p_graph = sub.add_parser("graph", help="Internal import graph") - p_graph.add_argument("repo", help="Path to repository") - p_graph.add_argument("--lang", help="Filter to a single language") - - p_symbols = sub.add_parser( - "symbols", help="Symbol name extraction and pattern clustering" - ) - - p_symbols.add_argument("repo", help="Path to repository") - p_symbols.add_argument("--lang", help="Filter to a single language") - - p_tests = sub.add_parser( - "tests", help="Test-to-source mapping and framework detection" - ) - - p_tests.add_argument("repo", help="Path to repository") - p_update = sub.add_parser("update", help="Update or create AGENTS.md") - p_update.add_argument("repo", help="Path to repository") - - p_update.add_argument( - "--section", - action="append", - help="Regenerate only the named section; may be repeated", - ) - - p_update.add_argument( - "--exclude-section", - action="append", - help="Skip regenerating the named section; may be repeated", - ) - - p_update.add_argument( - "--force", - action="store_true", - help="Rebuild AGENTS.md from regenerated sections only", - ) - - p_update.add_argument("--out", metavar="FILE", help="Write markdown to file") - p_update.add_argument( - "--profile", - default=DEFAULT_OUTPUT_PROFILE, - help=f"Output profile (default: {DEFAULT_OUTPUT_PROFILE})", - ) - - p_update.add_argument( - "--layout", - default=DEFAULT_OUTPUT_LAYOUT, - help=f"Output layout (default: {DEFAULT_OUTPUT_LAYOUT})", - ) - - p_generate = sub.add_parser( - "generate", help="Generate AGENTS.md markdown from repository analysis" - ) - - p_generate.add_argument("repo", help="Path to repository") - p_generate.add_argument( - "--reference", - action="append", - help="Reference repository path or URL; may be repeated", - ) - - p_generate.add_argument( - "--interactive", - action="store_true", - help="Prompt for missing or ambiguous generation inputs", - ) - - p_generate.add_argument("--out", metavar="FILE", help="Write markdown to file") - p_generate.add_argument( - "--profile", - default=DEFAULT_OUTPUT_PROFILE, - help=f"Output profile (default: {DEFAULT_OUTPUT_PROFILE})", - ) - - p_generate.add_argument( - "--layout", - default=DEFAULT_OUTPUT_LAYOUT, - help=f"Output layout (default: {DEFAULT_OUTPUT_LAYOUT})", - ) - - for p in [ - p_scan, - p_measure, - p_config, - p_git, - p_graph, - p_symbols, - p_tests, - p_analyze, - ]: - p.add_argument("--pretty", action="store_true", help="Pretty-print JSON output") - p.add_argument("--out", metavar="FILE", help="Write output to file") - - args = parser.parse_args(argv) - - dispatch = { - "analyze": cmd_analyze, - "scan": lambda a: _single_script_cmd("scan", a), - "measure": lambda a: _single_script_cmd("measure", a), - "config": lambda a: _single_script_cmd("config", a), - "git": lambda a: _single_script_cmd("git", a), - "graph": lambda a: _single_script_cmd("graph", a), - "symbols": lambda a: _single_script_cmd("symbols", a), - "tests": lambda a: _single_script_cmd("tests", a), - "update": cmd_update, - "generate": cmd_generate, - } - - handler = dispatch.get(args.command) - - if not handler: - parser.print_help() - return 1 - - return handler(args) diff --git a/agentskill/src/bin/agsk.rs b/agentskill/src/bin/agsk.rs new file mode 100644 index 0000000..550511d --- /dev/null +++ b/agentskill/src/bin/agsk.rs @@ -0,0 +1,3 @@ +fn main() { + std::process::exit(agentskill::run()); +} diff --git a/agentskill/src/lib.rs b/agentskill/src/lib.rs new file mode 100644 index 0000000..2934d21 --- /dev/null +++ b/agentskill/src/lib.rs @@ -0,0 +1,278 @@ +use clap::{Args, Parser, Subcommand}; + +use agentskill_core::output::write_value; + +#[derive(Parser)] +#[command( + name = "agentskill", + version, + about = "Analyze repositories and synthesize AGENTS.md" +)] +pub struct Cli { + #[arg(long, global = true, help = "Pretty-print JSON output")] + pretty: bool, + #[arg( + long, + global = true, + value_name = "FILE", + help = "Write output to a file instead of stdout" + )] + out: Option, + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + #[command(about = "Run all analyzers and merge output")] + Analyze(AnalyzeArgs), + #[command(about = "Directory tree and file inventory")] + Scan(RepoLangArgs), + #[command(about = "Exact formatting metrics")] + Measure(RepoLangArgs), + #[command(about = "Formatter, linter, and type-checker detection")] + Config(RepoArgs), + #[command(about = "Commit log and branch analysis")] + Git(RepoArgs), + #[command(about = "Internal import graph")] + Graph(RepoLangArgs), + #[command(about = "Symbol name extraction and pattern clustering")] + Symbols(RepoLangArgs), + #[command(about = "Test-to-source mapping and framework detection")] + Tests(RepoArgs), + #[command(about = "Generate AGENTS.md markdown from repository analysis")] + Generate(GenerateArgs), + #[command(about = "Update or create AGENTS.md")] + Update(UpdateArgs), +} + +#[derive(Args)] +struct RepoArgs { + repo: String, +} + +#[derive(Args)] +struct RepoLangArgs { + repo: String, + #[arg(long)] + lang: Option, +} + +#[derive(Args)] +struct AnalyzeArgs { + #[arg(required = true)] + repos: Vec, + #[arg(long)] + lang: Option, + #[arg(long = "reference", action = clap::ArgAction::Append)] + references: Vec, +} + +#[derive(Args)] +struct GenerateArgs { + repo: String, + #[arg(long = "reference", action = clap::ArgAction::Append)] + references: Vec, + #[arg(long)] + interactive: bool, + #[arg(long, default_value = "concise")] + profile: String, + #[arg(long, default_value = "single")] + layout: String, +} + +#[derive(Args)] +struct UpdateArgs { + repo: String, + #[arg(long = "section", action = clap::ArgAction::Append)] + sections: Vec, + #[arg(long = "exclude-section", action = clap::ArgAction::Append)] + excluded_sections: Vec, + #[arg(long)] + force: bool, + #[arg(long, default_value = "concise")] + profile: String, + #[arg(long, default_value = "single")] + layout: String, +} + +pub fn run() -> i32 { + let cli = Cli::parse(); + + match dispatch(cli) { + Ok(failed) => i32::from(failed), + Err(error) => { + eprintln!("{error}"); + 1 + } + } +} + +fn dispatch(cli: Cli) -> agentskill_core::Result { + let pretty = cli.pretty; + + let out = cli.out.as_deref(); + match cli.command { + Commands::Analyze(args) => { + agentskill_core::reference::load_reference_documents(&args.references)?; + write_value( + &agentskill_analyzers::run_many(&args.repos, args.lang.as_deref()), + pretty, + out, + ) + .map(|()| false) + } + Commands::Scan(args) => { + write_analyzer("scan", &args.repo, args.lang.as_deref(), pretty, out) + } + Commands::Measure(args) => { + write_analyzer("measure", &args.repo, args.lang.as_deref(), pretty, out) + } + Commands::Config(args) => write_analyzer("config", &args.repo, None, pretty, out), + Commands::Git(args) => write_analyzer("git", &args.repo, None, pretty, out), + Commands::Graph(args) => { + write_analyzer("graph", &args.repo, args.lang.as_deref(), pretty, out) + } + Commands::Symbols(args) => { + write_analyzer("symbols", &args.repo, args.lang.as_deref(), pretty, out) + } + Commands::Tests(args) => write_analyzer("tests", &args.repo, None, pretty, out), + Commands::Generate(args) => { + if pretty { + return Err(agentskill_core::AgentskillError::InvalidArgument( + "generate does not support --pretty".into(), + )); + } + + let answers = if args.interactive { + agentskill_generation::collect_interactive_answers(&args.repo, &args.references)? + } else { + std::collections::BTreeMap::new() + }; + agentskill_generation::generate_with_answers( + &args.repo, + out, + &args.references, + args.interactive, + &args.profile, + &args.layout, + &answers, + ) + .map(|()| false) + } + Commands::Update(args) => { + if pretty { + return Err(agentskill_core::AgentskillError::InvalidArgument( + "update does not support --pretty".into(), + )); + } + agentskill_generation::update( + &args.repo, + out, + &args.sections, + &args.excluded_sections, + args.force, + &args.profile, + &args.layout, + ) + .map(|()| false) + } + } +} + +fn write_analyzer( + name: &str, + repo: &str, + lang: Option<&str>, + pretty: bool, + out: Option<&str>, +) -> agentskill_core::Result { + let value = agentskill_analyzers::run_one(name, repo, lang); + let failed = value.get("error").is_some(); + write_value(&value, pretty, out)?; + + Ok(failed) +} + +#[cfg(test)] +mod unit_tests { + use std::fs; + + use super::{Cli, dispatch}; + use clap::Parser; + use tempfile::tempdir; + + fn example() -> String { + format!( + "{}/../agentskill-skill/examples/rust", + env!("CARGO_MANIFEST_DIR") + ) + } + + #[test] + fn dispatches_all_analyzers_and_document_commands() { + let directory = tempdir().unwrap(); + + let repo = directory.path().to_string_lossy().into_owned(); + fs::write(directory.path().join("main.rs"), "fn main() {}\n").unwrap(); + + let analyzers = [ + "scan", "measure", "config", "git", "graph", "symbols", "tests", + ]; + let output_directory = format!("target/agentskill-test-output-{}", std::process::id()); + fs::create_dir_all(&output_directory).unwrap(); + + for (index, analyzer) in analyzers.into_iter().enumerate() { + let output = format!("{output_directory}/{index}.json"); + let cli = + Cli::try_parse_from(["agentskill", "--pretty", "--out", &output, analyzer, &repo]) + .unwrap(); + dispatch(cli).unwrap(); + + assert!(std::path::Path::new(&output).exists()); + } + + let analyze_output = format!("{output_directory}/analyze.json"); + dispatch( + Cli::try_parse_from([ + "agentskill", + "--out", + &analyze_output, + "analyze", + &example(), + ]) + .unwrap(), + ) + .unwrap(); + + let generated = directory.path().join("AGENTS.md"); + let generated = generated.to_string_lossy().into_owned(); + dispatch( + Cli::try_parse_from(["agentskill", "--out", &generated, "generate", &repo]).unwrap(), + ) + .unwrap(); + dispatch(Cli::try_parse_from(["agentskill", "update", &repo, "--force"]).unwrap()).unwrap(); + fs::remove_dir_all(output_directory).unwrap(); + } + + #[test] + fn rejects_pretty_document_commands() { + let directory = tempdir().unwrap(); + fs::write(directory.path().join("main.rs"), "fn main() {}\n").unwrap(); + + let repo = directory.path().to_string_lossy().into_owned(); + let generate = Cli::try_parse_from(["agentskill", "--pretty", "generate", &repo]); + + assert!(dispatch(generate.unwrap()).is_err()); + let update = Cli::try_parse_from(["agentskill", "--pretty", "update", &repo]); + + assert!(dispatch(update.unwrap()).is_err()); + } + + #[test] + fn reports_analyzer_errors_as_failed_commands() { + let cli = Cli::try_parse_from(["agentskill", "scan", "/missing/repository"]).unwrap(); + + assert!(dispatch(cli).unwrap()); + } +} diff --git a/agentskill/src/main.rs b/agentskill/src/main.rs new file mode 100644 index 0000000..550511d --- /dev/null +++ b/agentskill/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + std::process::exit(agentskill::run()); +} diff --git a/agentskill/tests/cli.rs b/agentskill/tests/cli.rs new file mode 100644 index 0000000..fedb9cc --- /dev/null +++ b/agentskill/tests/cli.rs @@ -0,0 +1,29 @@ +use std::process::Command; + +#[test] +fn both_binaries_report_version() { + for binary in [env!("CARGO_BIN_EXE_agentskill"), env!("CARGO_BIN_EXE_agsk")] { + let output = Command::new(binary).arg("--version").output().unwrap(); + + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("2.0.0")); + } +} + +#[test] +fn cli_emits_json_for_analyzer() { + let example = format!( + "{}/../agentskill-skill/examples/rust", + env!("CARGO_MANIFEST_DIR") + ); + + let output = Command::new(env!("CARGO_BIN_EXE_agentskill")) + .args(["scan", &example, "--pretty"]) + .output() + .unwrap(); + + assert!(output.status.success()); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(value["summary"]["total_files"], 3); +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..322e5ef --- /dev/null +++ b/deny.toml @@ -0,0 +1,19 @@ +[advisories] +yanked = "deny" + +[licenses] +confidence-threshold = 0.8 +allow = [ + "Apache-2.0", + "MIT", + "Unicode-3.0", +] + +[bans] +multiple-versions = "warn" +wildcards = "allow" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/docs/reference/README.md b/docs/reference/README.md deleted file mode 100644 index e01c2d5..0000000 --- a/docs/reference/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# API Reference - -This directory documents the packaged `agentskill/` namespace as shipped. - -The public CLI surface is the installed `agentskill` command wired through -`agentskill.main:main`. Analyzer implementations live in `agentskill.commands`, -shared orchestration and generation/update helpers live in `agentskill.lib`, -and reusable low-level helpers live in `agentskill.common`. - -Reference pages: - -- [`cli.md`](./cli.md): packaged CLI entrypoint, subcommands, and dispatch -- [`commands.md`](./commands.md): analyzer command modules and their primary callables -- [`library.md`](./library.md): orchestration, output, update, generation, and reference helpers -- [`common.md`](./common.md): shared registries, filesystem helpers, and repository walking utilities - -This reference is intentionally static and release-oriented. It describes the -current packaged layout and contributor extension points rather than every -private helper. diff --git a/docs/reference/cli.md b/docs/reference/cli.md deleted file mode 100644 index 1d29586..0000000 --- a/docs/reference/cli.md +++ /dev/null @@ -1,55 +0,0 @@ -# CLI Reference - -## Canonical Entry Point - -- Module: `agentskill.main` -- Published console script: `agentskill = "agentskill.main:main"` -- Primary callable: `main(argv: list[str] | None = None) -> int` - -`agentskill.main` is the source of truth for the installed CLI. It owns global -argument parsing, subcommand registration, and dispatch into analyzer, -generation, and update workflows. - -## Public Command Families - -- `agentskill analyze [ ...]` - Runs the full analyzer stack and emits merged JSON. -- `agentskill scan|measure|config|git|graph|symbols|tests ` - Runs one analyzer and emits that analyzer's JSON payload. -- `agentskill generate ` - Renders a fresh `AGENTS.md` document to stdout or `--out`. -- `agentskill update ` - Regenerates sections and merges them into an existing `AGENTS.md`, or creates - one when missing. - -## Dispatch Model - -- `cmd_analyze(args)` calls [`agentskill.lib.runner.run_many`](./library.md#runner) - and writes public JSON through [`agentskill.lib.output.write_output`](./library.md#output-and-schema). -- `_single_script_cmd(command_name, args)` routes analyzer subcommands through - the `COMMANDS` registry in `agentskill.lib.runner`. -- `cmd_generate(args)` delegates to - [`agentskill.lib.generate_runner.generate_agents`](./library.md#generation-and-update). -- `cmd_update(args)` delegates to - [`agentskill.lib.update_runner.update_agents`](./library.md#generation-and-update). - -## Flags and Stable Behavior - -- `--pretty` applies to JSON-producing analyzer flows only. -- `--out` writes JSON or markdown to a file instead of stdout. -- `--reference` is supported by `analyze` and `generate`. -- `--interactive` is supported by `generate` only. -- `--profile` is supported by `generate` and `update`. Accepted values are `concise` (default) and `comprehensive`. - - `concise` emits operational rules and key facts only; representative code snippets and secondary explanatory bullets are suppressed. - - `comprehensive` includes everything from concise plus representative snippets, annotation measurements, and expanded rationale bullets. - - All profiles are deterministic from the same analyzer results and preserve the same section order and headings. - - When `--layout split` is active, the `--profile` flag is ignored: the primary file is always concise and the companion is always comprehensive. - - When `--layout multifile` is active, `--profile` controls the density of content in each section file. The default profile for multifile is `comprehensive`. -- `--layout` is supported by `generate`. Accepted values are `single` (default), `split`, and `multifile`. - - `single` writes one complete markdown file. Without `--out`, prints to stdout. - - `split` writes two files: a concise primary document and an `AGENTS.reference.md` companion with comprehensive content. The primary file contains a relative link to the companion. Without `--out`, split writes into the target repo using `/AGENTS.md` as the primary path. - - `multifile` writes a root index file plus per-section markdown files in a `.agentskill/` directory beside the primary output. Section filenames follow a stable numbering scheme: `01_OVERVIEW.md`, `02_REPOSITORY_STRUCTURE.md`, `05_COMMANDS_AND_WORKFLOWS.md`, `06_CODE_FORMATTING.md`, `07_NAMING_CONVENTIONS.md`, `08_TYPE_ANNOTATIONS.md`, `09_IMPORTS.md`, `10_ERROR_HANDLING.md`, `11_COMMENTS_AND_DOCSTRINGS.md`, `12_TESTING.md`, `13_GIT.md`, `14_DEPENDENCIES_AND_TOOLING.md`, `15_RED_LINES.md`. Each section file contains a backlink to the root. Without `--out`, multifile writes into the target repo using `/AGENTS.md` as the root path. - - `update --layout` is not yet supported for `split` or `multifile` and is explicitly rejected. -- `--section`, `--exclude-section`, and `--force` are supported by `update`. - -Release-grade CLI contract tests live in `tests/test_cli_contract.py`. diff --git a/docs/reference/commands.md b/docs/reference/commands.md deleted file mode 100644 index 0eacd5b..0000000 --- a/docs/reference/commands.md +++ /dev/null @@ -1,44 +0,0 @@ -# Command Modules - -The analyzer command modules live in `agentskill.commands`. Each module exposes -one primary analyzer callable that accepts a repository path and returns a JSON -serializable payload, plus a `main()` wrapper for direct execution. - -## Inventory - -- `agentskill.commands.scan` - Primary callable: `scan(repo_path: str, lang_filter: str | None = None) -> dict` - Role: repository walk, file inventory, language summary, and suggested read order. -- `agentskill.commands.measure` - Primary callable: `measure(repo_path: str, lang_filter: str | None = None) -> dict` - Role: formatting metrics such as indentation, line-length percentiles, and blank-line distributions. -- `agentskill.commands.config` - Primary callable: `detect(repo_path: str) -> dict` - Role: formatter, linter, type-checker, build-tool, and project-marker detection. -- `agentskill.commands.git` - Primary callable: `analyze(repo_path: str) -> dict` - Role: commit-prefix, branch-shape, merge-strategy, and repository-history analysis. -- `agentskill.commands.graph` - Primary callable: `build_graph(repo_path: str, lang_filter: str | None = None) -> dict` - Role: import, include, require, and dependency-edge extraction across supported languages. -- `agentskill.commands.symbols` - Primary callable: `extract_symbols(repo_path: str, lang_filter: str | None = None) -> dict` - Role: symbol-name extraction and naming-pattern clustering. -- `agentskill.commands.tests` - Primary callable: `analyze_tests(repo_path: str) -> dict` - Role: test-framework detection, test-to-source mapping, and coverage-shape inference. - -## Direct Wrappers - -Each analyzer module also exposes `main(argv: list[str] | None = None) -> int` -for direct wrapper execution. Those wrappers remain supported under `scripts/`, -but they are secondary to the installed `agentskill` CLI. - -## Extension Guidance - -- Add new analyzer implementation logic inside `agentskill.commands`. -- Keep analyzer return values JSON-serializable. -- Follow the error-payload convention used elsewhere in the codebase: - `{"error": "...", "script": ""}`. -- Wire new public CLI exposure through `agentskill.main`, not by expanding - wrapper-only behavior under `scripts/`. diff --git a/docs/reference/common.md b/docs/reference/common.md deleted file mode 100644 index 03942ce..0000000 --- a/docs/reference/common.md +++ /dev/null @@ -1,40 +0,0 @@ -# Common Helpers Reference - -The `agentskill.common` package holds low-level utilities reused across -analyzers and library modules. - -## Language Registry - -- Module: `agentskill.common.languages` -- Primary helpers: - `all_language_specs() -> tuple[LanguageSpec, ...]` - `language_by_id(language_id: str) -> LanguageSpec | None` - `language_for_extension(extension: str) -> LanguageSpec | None` - `language_for_path(path: str | Path) -> LanguageSpec | None` - `is_supported_language(language_id: str) -> bool` - -This registry defines the supported language matrix, filename extensions, -package/config markers, test patterns, and source-root hints used throughout -the analyzer stack. - -## Filesystem Helpers - -- Module: `agentskill.common.fs` -- Primary helpers: - `validate_repo(path: str) -> Path` - `read_text(path: Path, max_bytes: int | None = MAX_FILE_BYTES) -> str` - `count_lines(path: Path) -> int` - -These helpers provide repo-path validation and tolerant file reads for analyzer -work that must keep going across partially broken or unusual repositories. - -## Repository Walking and Constants - -- Module: `agentskill.common.walk` - Role: repository traversal and file filtering helpers used by analyzers. -- Module: `agentskill.common.constants` - Role: shared constants such as byte limits and skip lists. - -Keep new low-level helpers here only when they are genuinely reusable across -multiple analyzers or library modules. Orchestration-level behavior belongs in -`agentskill.lib` instead. diff --git a/docs/reference/library.md b/docs/reference/library.md deleted file mode 100644 index 0472831..0000000 --- a/docs/reference/library.md +++ /dev/null @@ -1,90 +0,0 @@ -# Library Reference - -The `agentskill.lib` package contains orchestration and document-generation -helpers that sit above the analyzer implementations. - -## Runner - -- Module: `agentskill.lib.runner` -- Primary callables: - `run_all(repo: str, lang_filter: str | None = None, references: list[str] | None = None) -> dict` - `run_many(repos: list[str], lang_filter: str | None = None, references: list[str] | None = None) -> dict` - -This module owns the analyzer registry (`COMMANDS`), parallel analyzer -execution, timeout handling, and multi-repo aggregation. - -## Output and Schema - -- Module: `agentskill.lib.output` - Primary helpers: `write_output(...)`, `run_and_output(...)`, `validate_out_path(...)` -- Module: `agentskill.lib.output_schema` - Primary helper: `validate_public_output(data: object, *, mode: str) -> None` - -These modules validate and serialize public JSON output, enforce `--out` path -rules, and keep the CLI-facing output contract consistent. - -## Generation and Update - -- Module: `agentskill.lib.generate_runner` - Primary callables: - `render_agents_markdown(...) -> str` - `generate_agents(...) -> int` -- Module: `agentskill.lib.update_runner` - Primary callables: - `render_agents_sections(...) -> dict[str, AgentsSection]` - `update_agents(...) -> int` -- Module: `agentskill.lib.update_merge` - Primary helper: `merge_agents_document(...)` -- Module: `agentskill.lib.update_feedback` - Primary helper: `load_feedback(repo_path: str | Path) -> UpdateFeedback` -- Module: `agentskill.lib.output_profiles` - Primary callables: `validate_output_profile(profile: str) -> str` - Constants: `DEFAULT_OUTPUT_PROFILE`, `SUPPORTED_OUTPUT_PROFILES` -- Module: `agentskill.lib.output_layouts` - Primary callables: `validate_output_layout(layout: str) -> str` - Constants: `DEFAULT_OUTPUT_LAYOUT`, `SUPPORTED_OUTPUT_LAYOUTS` -- Module: `agentskill.lib.profile_rendering` - Primary callables: `combine_section_body(...)`, `build_companion_document(...)`, `inject_split_link(...)`, `companion_path(...)`, `companion_relative_link(...)` -- Module: `agentskill.lib.multifile_output` - Primary callables: `section_file_path(...)`, `build_section_file(...)`, `build_root_index(...)` - Constants: `SECTION_FILE_MAP`, `SECTION_DESCRIPTIONS`, `SECTION_DIR` - -`generate_runner` produces a fresh document without merge semantics. -`update_runner` regenerates sections and merges them into an existing -`AGENTS.md` unless `--force` requests a clean rebuild. - -Profile and layout handling: `--profile` controls content density (`concise` -or `comprehensive`). `--layout` controls output packaging (`single`, `split`, -or `multifile`). Split layout writes a concise primary plus comprehensive -companion regardless of the profile flag. Multifile layout writes a root -index plus per-section files using the specified profile (default -`comprehensive`). - -## Reference and Interactive Flows - -- Module: `agentskill.lib.reference_flow` - Primary helper: `load_reference_documents(references: list[str] | None) -> list[ReferenceDocument]` -- Module: `agentskill.lib.reference_initialization` - Primary helper: `initialize_from_references(...)` -- Module: `agentskill.lib.reference_adaptation` - Role: compare reference conventions against target analysis signals -- Module: `agentskill.lib.reference_questions` - Role: generate follow-up questions when references and target analysis diverge -- Module: `agentskill.lib.references` - Role: local and remote reference loading -- Module: `agentskill.lib.interactive_runner` - Role: prompt orchestration and interactive-note injection - -These modules power `--reference` and `--interactive` behavior for the packaged -generation flow. - -## Other Shared Helpers - -- `agentskill.lib.cli_entrypoint` - Shared analyzer-wrapper argument parsing for direct script entrypoints. -- `agentskill.lib.logging_utils` - Stderr logger setup for internal use. -- `agentskill.lib.parsers` - Safe TOML and YAML parsing helpers. -- `agentskill.lib.agents_document` - AGENTS section parsing and section-object helpers. diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..500f3d1 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,7 @@ +pre-commit: + piped: true + follow: true + + commands: + rust: + run: agentskill-scripts/pre-commit.sh {staged_files} diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 4c670c9..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,75 +0,0 @@ -[build-system] -requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" - -[project] -name = "agsk" -version = "1.4.0" -description = "Analyze repositories and synthesize AGENTS.md" -readme = "README.md" -requires-python = ">=3.10" - -dependencies = [ - 'tomli; python_version < "3.11"', - 'PyYAML>=6.0; python_version < "3.11"', -] - -[project.optional-dependencies] -parsers = [ - "PyYAML>=6.0", -] -dev = [ - "mypy>=1.10", - "pre-commit>=4.4", - "pytest>=7.4", - "pytest-cov>=4.1", - "PyYAML>=6.0", - "ruff>=0.15.12", - "tomli>=2.4.1", - "types-PyYAML>=6.0", -] - -[project.scripts] -agentskill = "agentskill.main:main" - -[tool.setuptools.packages.find] -where = ["."] -include = ["agentskill*"] - -[tool.pytest.ini_options] -testpaths = ["tests"] - -[tool.ruff] -target-version = "py39" -exclude = [ - ".pytest_cache", - ".ruff_cache", - ".venv", - "__pycache__", -] - -[tool.ruff.lint] -select = ["B", "C4", "E4", "E7", "E9", "F", "I", "N", "SIM", "UP", "W"] -ignore = ["E402"] - -[tool.mypy] -python_version = "3.10" -files = ["agentskill", "scripts", "tests"] -mypy_path = "tests" -explicit_package_bases = true -check_untyped_defs = true -warn_unused_ignores = true -warn_redundant_casts = true -warn_return_any = false -warn_unreachable = true -show_error_codes = true -pretty = true - -[tool.coverage.run] -source = ["."] -omit = [ - "tests/*", -] - -[tool.coverage.report] -show_missing = true diff --git a/scripts/analyze.py b/scripts/analyze.py deleted file mode 100644 index 53127d1..0000000 --- a/scripts/analyze.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -text = str(ROOT) - -if text not in sys.path: - sys.path.insert(0, text) - -from agentskill.main import main - -if __name__ == "__main__": - raise SystemExit(main(["analyze", *sys.argv[1:]])) diff --git a/scripts/config.py b/scripts/config.py deleted file mode 100644 index 2e56526..0000000 --- a/scripts/config.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -text = str(ROOT) - -if text not in sys.path: - sys.path.insert(0, text) - -from agentskill.commands.config import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/generate.py b/scripts/generate.py deleted file mode 100644 index 658c410..0000000 --- a/scripts/generate.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -text = str(ROOT) - -if text not in sys.path: - sys.path.insert(0, text) - -from agentskill.main import main - -if __name__ == "__main__": - raise SystemExit(main(["generate", *sys.argv[1:]])) diff --git a/scripts/git.py b/scripts/git.py deleted file mode 100644 index f7bddbd..0000000 --- a/scripts/git.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -text = str(ROOT) - -if text not in sys.path: - sys.path.insert(0, text) - -from agentskill.commands.git import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/graph.py b/scripts/graph.py deleted file mode 100644 index 017562e..0000000 --- a/scripts/graph.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -text = str(ROOT) - -if text not in sys.path: - sys.path.insert(0, text) - -from agentskill.commands.graph import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/measure.py b/scripts/measure.py deleted file mode 100644 index 6fbee65..0000000 --- a/scripts/measure.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -text = str(ROOT) - -if text not in sys.path: - sys.path.insert(0, text) - -from agentskill.commands.measure import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/scan.py b/scripts/scan.py deleted file mode 100644 index 2d8582a..0000000 --- a/scripts/scan.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -text = str(ROOT) - -if text not in sys.path: - sys.path.insert(0, text) - -from agentskill.commands.scan import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/symbols.py b/scripts/symbols.py deleted file mode 100644 index 45967de..0000000 --- a/scripts/symbols.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -text = str(ROOT) - -if text not in sys.path: - sys.path.insert(0, text) - -from agentskill.commands.symbols import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/tests.py b/scripts/tests.py deleted file mode 100644 index 2f580b8..0000000 --- a/scripts/tests.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -text = str(ROOT) - -if text not in sys.path: - sys.path.insert(0, text) - -from agentskill.commands.tests import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/update.py b/scripts/update.py deleted file mode 100644 index 558f4cc..0000000 --- a/scripts/update.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -text = str(ROOT) - -if text not in sys.path: - sys.path.insert(0, text) - -from agentskill.main import main - -if __name__ == "__main__": - raise SystemExit(main(["update", *sys.argv[1:]])) diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index e50f5aa..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -text = str(ROOT) - -if text not in sys.path: - sys.path.insert(0, text) diff --git a/tests/contract_utils.py b/tests/contract_utils.py deleted file mode 100644 index 200c72f..0000000 --- a/tests/contract_utils.py +++ /dev/null @@ -1,46 +0,0 @@ -import json -from pathlib import Path - -from agentskill.lib.output_schema import ANALYZER_NAMES - -CONTRACTS_DIR = Path(__file__).resolve().parent / "contracts" - - -def normalize_contract(value): - if isinstance(value, dict): - keys = set(value) - - if keys and keys.issubset(set(ANALYZER_NAMES)): - return {k: normalize_contract(value[k]) for k in sorted(value)} - - return {k: normalize_contract(value[k]) for k in sorted(value)} - - if isinstance(value, list): - if not value: - return [] - - return [normalize_contract(value[0])] - - if isinstance(value, bool): - return "bool" - - if isinstance(value, (int, float)): - return "number" - - if isinstance(value, str): - return "str" - - if value is None: - return "null" - - return type(value).__name__ - - -def load_contract(name: str) -> dict: - return json.loads((CONTRACTS_DIR / name).read_text()) - - -def assert_matches_contract(actual: dict, contract_name: str) -> None: - normalized = normalize_contract(actual) - expected = load_contract(contract_name) - assert normalized == expected diff --git a/tests/test_agents_document.py b/tests/test_agents_document.py deleted file mode 100644 index 52831be..0000000 --- a/tests/test_agents_document.py +++ /dev/null @@ -1,140 +0,0 @@ -from agentskill.lib.agents_document import ( - AgentsSection, - add_or_replace_section, - get_section, - normalize_section_name, - parse_agents_document, - replace_section, - serialize_agents_document, -) - - -def test_parse_empty_document(): - document = parse_agents_document("") - - assert document.preamble == "" - assert document.sections == [] - assert serialize_agents_document(document) == "" - - -def test_parse_preamble_and_multiple_sections(): - text = ( - "Intro line.\n" - "Still preamble.\n\n" - "# Overview\n" - "Top-level body.\n" - "## Details\n" - "More detail.\n" - ) - - document = parse_agents_document(text) - - assert document.preamble == "Intro line.\nStill preamble.\n\n" - assert document.sections == [ - AgentsSection( - heading_text="Overview", - normalized_name="", - heading_level=1, - body="Top-level body.\n", - ), - AgentsSection( - heading_text="Details", - normalized_name="", - heading_level=2, - body="More detail.\n", - ), - ] - - -def test_unknown_custom_sections_round_trip_unchanged(): - text = ( - "Custom intro.\n\n" - "## House Style\n" - "Keep this text.\n" - "### Team Notes\n" - "- Preserve bullets\n" - ) - - assert serialize_agents_document(parse_agents_document(text)) == ( - "Custom intro.\n\n" - "## House Style\n\n" - "Keep this text.\n\n" - "### Team Notes\n\n" - "- Preserve bullets\n\n" - ) - - -def test_lookup_uses_normalized_section_names(): - text = "# Coding Conventions\nUse double quotes.\n" - document = parse_agents_document(text) - - assert normalize_section_name(" Coding conventions ") == "coding conventions" - assert get_section(document, "coding conventions") == AgentsSection( - heading_text="Coding Conventions", - normalized_name="", - heading_level=1, - body="Use double quotes.\n", - ) - - -def test_replace_section_updates_only_first_matching_section(): - text = "Before.\n\n# Overview\nOriginal overview.\n## Conventions\nKeep this.\n" - document = parse_agents_document(text) - - replacement = AgentsSection( - heading_text="Overview", - normalized_name="", - heading_level=1, - body="Updated overview.\n", - ) - - updated = replace_section(document, replacement) - - assert serialize_agents_document(updated) == ( - "Before.\n\n# Overview\n\nUpdated overview.\n\n## Conventions\n\nKeep this.\n\n" - ) - - -def test_add_or_replace_section_appends_when_missing(): - text = "# Overview\nBase content.\n" - document = parse_agents_document(text) - - section = AgentsSection( - heading_text="Custom Notes", - normalized_name="", - heading_level=2, - body="Appended content.\n", - ) - - updated = add_or_replace_section(document, section) - assert serialize_agents_document(updated) == ( - "# Overview\n\nBase content.\n\n## Custom Notes\n\nAppended content.\n\n" - ) - - -def test_round_trip_preserves_representative_document(): - text = ( - "Generated by agentskill.\n" - "Manual notes stay here.\n\n" - "# Overview\n" - "Summary paragraph.\n\n" - "## Commands\n" - "```bash\n" - "agentskill analyze repo --pretty\n" - "```\n\n" - "### Local Notes\n" - "Do not remove this section.\n" - ) - - assert serialize_agents_document(parse_agents_document(text)) == ( - "Generated by agentskill.\n" - "Manual notes stay here.\n\n" - "# Overview\n\n" - "Summary paragraph.\n\n" - "## Commands\n\n" - "```bash\n" - "agentskill analyze repo --pretty\n" - "```\n\n" - "### Local Notes\n\n" - "Do not remove this section.\n\n" - ) diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index da7a040..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,100 +0,0 @@ -import json -import sys -from pathlib import Path - -if sys.version_info >= (3, 11): - import tomllib -else: - import tomli as tomllib - -from test_support import create_repo, create_sample_repo, write - -from agentskill.main import main as packaged_main - - -def test_cli_scan_outputs_json(tmp_path, capsys): - repo = create_sample_repo(tmp_path) - exit_code = packaged_main(["scan", str(repo), "--pretty"]) - - assert exit_code == 0 - - assert exit_code == 0 - - output = json.loads(capsys.readouterr().out) - assert output["summary"]["total_files"] >= 4 - - -def test_cli_analyze_runs_all_commands(tmp_path, capsys): - repo = create_sample_repo(tmp_path) - exit_code = packaged_main(["analyze", str(repo), "--pretty"]) - - assert exit_code == 0 - - output = json.loads(capsys.readouterr().out) - - assert set(output) == { - "scan", - "measure", - "config", - "git", - "graph", - "symbols", - "tests", - } - - -def test_cli_analyze_accepts_reference_without_changing_output_shape(tmp_path, capsys): - repo = create_sample_repo(tmp_path / "target") - reference = create_repo(tmp_path, name="reference") - write(reference, "AGENTS.md", "# AGENTS\n\n## 12. Testing\nUse pytest.\n") - - exit_code = packaged_main( - ["analyze", str(repo), "--reference", str(reference), "--pretty"] - ) - - assert exit_code == 0 - output = json.loads(capsys.readouterr().out) - - assert set(output) == { - "scan", - "measure", - "config", - "git", - "graph", - "symbols", - "tests", - } - - -def test_cli_analyze_reports_invalid_reference_path(tmp_path, capsys): - repo = create_sample_repo(tmp_path / "target") - missing = tmp_path / "missing-reference" - exit_code = packaged_main(["analyze", str(repo), "--reference", str(missing)]) - - assert exit_code == 1 - assert capsys.readouterr().err == f"reference path does not exist: {missing}\n" - - -def test_cli_writes_out_file_and_multi_repo_results(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - repo_one = create_sample_repo(tmp_path / "one") - repo_two = create_sample_repo(tmp_path / "two") - out_file = Path("report.json") - - exit_code = packaged_main( - ["analyze", str(repo_one), str(repo_two), "--out", str(out_file)] - ) - - assert exit_code == 0 - - payload = json.loads(out_file.read_text()) - assert set(payload) == {str(repo_one), str(repo_two)} - - -def test_pyproject_includes_cli_module_for_console_script(): - with Path("pyproject.toml").open("rb") as f: - data = tomllib.load(f) - - assert data["project"]["scripts"]["agentskill"] == "agentskill.main:main" - assert "py-modules" not in data.get("tool", {}).get("setuptools", {}) - assert data["tool"]["setuptools"]["packages"]["find"]["include"] == ["agentskill*"] diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py deleted file mode 100644 index 4a05e93..0000000 --- a/tests/test_cli_contract.py +++ /dev/null @@ -1,370 +0,0 @@ -import json - -from test_support import ( - commit_all, - create_repo, - create_sample_repo, - init_git_repo, - write, -) - -from agentskill.main import main - - -def create_committed_sample_repo(tmp_path, name: str = "repo"): - repo = create_sample_repo(tmp_path / name) - init_git_repo(repo) - commit_all(repo, "feat: initial") - return repo - - -def test_cli_help_lists_public_commands(capsys): - try: - main(["--help"]) - except SystemExit as exc: - exit_code = exc.code - else: - raise AssertionError("expected SystemExit from --help") - - assert exit_code == 0 - help_text = capsys.readouterr().out - - for command in [ - "analyze", - "scan", - "measure", - "config", - "git", - "graph", - "symbols", - "tests", - "generate", - "update", - ]: - assert command in help_text - - -def test_analyzer_commands_have_stable_successful_invocations(tmp_path, capsys): - repo = create_committed_sample_repo(tmp_path) - cases = [ - ("scan", ["--lang", "python"]), - ("measure", ["--lang", "python"]), - ("config", []), - ("git", []), - ("graph", ["--lang", "python"]), - ("symbols", ["--lang", "python"]), - ("tests", []), - ] - - for command, extra_args in cases: - exit_code = main([command, str(repo), *extra_args, "--pretty"]) - - assert exit_code == 0 - captured = capsys.readouterr() - assert captured.err == "" - output = json.loads(captured.out) - - if command == "scan": - assert output["summary"]["total_files"] >= 4 - elif command == "measure" or command == "config": - assert "python" in output - elif command == "git": - assert "error" not in output - elif command == "graph": - assert isinstance(output, dict) - elif command == "symbols": - assert "python" in output - elif command == "tests": - assert output["python"]["framework"] == "pytest" - - -def test_analyze_success_writes_json_to_stdout_and_not_stderr(tmp_path, capsys): - repo = create_committed_sample_repo(tmp_path) - exit_code = main(["analyze", str(repo), "--pretty"]) - - assert exit_code == 0 - captured = capsys.readouterr() - assert captured.err == "" - - output = json.loads(captured.out) - assert set(output) == { - "scan", - "measure", - "config", - "git", - "graph", - "symbols", - "tests", - } - - -def test_analyze_out_writes_file_and_suppresses_stdout(tmp_path, monkeypatch, capsys): - repo = create_committed_sample_repo(tmp_path) - monkeypatch.chdir(tmp_path) - exit_code = main(["analyze", str(repo), "--out", "report.json"]) - - assert exit_code == 0 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == "" - - payload = json.loads((tmp_path / "report.json").read_text()) - assert set(payload) == { - "scan", - "measure", - "config", - "git", - "graph", - "symbols", - "tests", - } - - -def test_analyze_reference_preserves_json_output_shape(tmp_path, capsys): - repo = create_committed_sample_repo(tmp_path, name="target") - reference = create_repo(tmp_path, name="reference") - write(reference, "AGENTS.md", "# AGENTS\n\n## 12. Testing\nUse pytest.\n") - exit_code = main(["analyze", str(repo), "--reference", str(reference), "--pretty"]) - - assert exit_code == 0 - captured = capsys.readouterr() - - assert captured.err == "" - assert set(json.loads(captured.out)) == { - "scan", - "measure", - "config", - "git", - "graph", - "symbols", - "tests", - } - - -def test_analyze_invalid_reference_fails_with_stderr_only(tmp_path, capsys): - repo = create_committed_sample_repo(tmp_path, name="target") - missing = tmp_path / "missing-reference" - exit_code = main(["analyze", str(repo), "--reference", str(missing)]) - - assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == f"reference path does not exist: {missing}\n" - - -def test_analyze_reference_missing_agents_file_fails_clearly(tmp_path, capsys): - repo = create_committed_sample_repo(tmp_path, name="target") - reference = create_repo(tmp_path, name="reference") - exit_code = main(["analyze", str(repo), "--reference", str(reference)]) - - assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == f"AGENTS.md not found in reference repository: {reference}\n" - - -def test_analyze_rejects_duplicate_reference_sources(tmp_path, capsys): - repo = create_committed_sample_repo(tmp_path, name="target") - reference = create_repo(tmp_path, name="reference") - write(reference, "AGENTS.md", "# AGENTS\n\n## 12. Testing\nUse pytest.\n") - - exit_code = main( - [ - "analyze", - str(repo), - "--reference", - str(reference), - "--reference", - str(reference), - ] - ) - - assert exit_code == 1 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == f"duplicate reference source: {reference}\n" - - -def test_analyze_reference_order_does_not_change_json_output(tmp_path, capsys): - repo = create_committed_sample_repo(tmp_path, name="target") - reference_a = create_repo(tmp_path, name="reference-a") - reference_b = create_repo(tmp_path, name="reference-b") - write(reference_a, "AGENTS.md", "# AGENTS\n\n## 12. Testing\nUse pytest.\n") - write(reference_b, "AGENTS.md", "# AGENTS\n\n## 13. Git\nUse rebase.\n") - - exit_code = main( - [ - "analyze", - str(repo), - "--reference", - str(reference_a), - "--reference", - str(reference_b), - "--pretty", - ] - ) - - assert exit_code == 0 - first = json.loads(capsys.readouterr().out) - - exit_code = main( - [ - "analyze", - str(repo), - "--reference", - str(reference_b), - "--reference", - str(reference_a), - "--pretty", - ] - ) - - assert exit_code == 0 - second = json.loads(capsys.readouterr().out) - - assert first == second - - -def test_generate_non_interactive_does_not_prompt(tmp_path, capsys, monkeypatch): - repo = create_repo(tmp_path) - - def fail_input(prompt: str) -> str: - raise AssertionError(f"unexpected prompt: {prompt}") - - monkeypatch.setattr("builtins.input", fail_input) - exit_code = main(["generate", str(repo)]) - - assert exit_code == 0 - captured = capsys.readouterr() - assert captured.out.startswith("# AGENTS.md\n\n## 1. Overview\n") - assert captured.err == "" - - -def test_generate_out_writes_file_and_suppresses_stdout(tmp_path, monkeypatch, capsys): - repo = create_sample_repo(tmp_path) - monkeypatch.chdir(tmp_path) - exit_code = main(["generate", str(repo), "--out", "generated/AGENTS.md"]) - - assert exit_code == 0 - captured = capsys.readouterr() - - assert captured.out == "" - assert captured.err == "" - - assert ( - (tmp_path / "generated/AGENTS.md") - .read_text() - .startswith("# AGENTS.md\n\n## 1. Overview\n") - ) - - -def test_generate_interactive_is_opt_in_and_writes_to_stdout( - tmp_path, capsys, monkeypatch -): - repo = create_repo(tmp_path) - prompts: list[str] = [] - answers = iter(["pytest -q", "feat:, fix:", "rebase"]) - - def fake_input(prompt: str) -> str: - prompts.append(prompt) - return next(answers) - - monkeypatch.setattr("builtins.input", fake_input) - exit_code = main(["generate", str(repo), "--interactive"]) - - assert exit_code == 0 - captured = capsys.readouterr() - assert captured.err == "" - assert len(prompts) == 3 - assert "Use `pytest -q` as the canonical test command." in captured.out - - -def test_generate_invalid_reference_fails_to_stderr_only(tmp_path, capsys): - repo = create_sample_repo(tmp_path / "target") - missing = tmp_path / "missing-reference" - exit_code = main(["generate", str(repo), "--reference", str(missing)]) - - assert exit_code == 1 - captured = capsys.readouterr() - - assert captured.out == "" - assert ( - f"Generate failed for repo {repo}: reference path does not exist: {missing}\n" - == captured.err - ) - - -def test_update_default_behavior_writes_repo_file_without_stdout(tmp_path, capsys): - repo = create_sample_repo(tmp_path) - exit_code = main(["update", str(repo)]) - - assert exit_code == 0 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == "" - assert ( - (repo / "AGENTS.md").read_text().startswith("# AGENTS.md\n\n## 1. Overview\n") - ) - - -def test_update_out_writes_custom_file_without_stdout(tmp_path, monkeypatch, capsys): - repo = create_sample_repo(tmp_path) - monkeypatch.chdir(tmp_path) - exit_code = main(["update", str(repo), "--out", "generated/AGENTS-new.md"]) - - assert exit_code == 0 - captured = capsys.readouterr() - assert captured.out == "" - assert captured.err == "" - assert (tmp_path / "generated/AGENTS-new.md").exists() - assert not (repo / "AGENTS.md").exists() - - -def test_update_rejects_invalid_section_with_exit_code_one(tmp_path, capsys): - repo = create_sample_repo(tmp_path) - exit_code = main(["update", str(repo), "--section", "unknown-section"]) - - assert exit_code == 1 - captured = capsys.readouterr() - - assert captured.out == "" - assert ( - "Update failed for repo " - f"{repo}: unsupported or unavailable sections: unknown-section\n" - == captured.err - ) - - -def test_unsupported_flag_combinations_fail_via_argparse(tmp_path, capsys): - repo = create_sample_repo(tmp_path) - - try: - main(["update", str(repo), "--reference", str(repo)]) - except SystemExit as exc: - exit_code = exc.code - else: - raise AssertionError("expected SystemExit from invalid argparse usage") - - assert exit_code == 2 - assert "unrecognized arguments: --reference" in capsys.readouterr().err - - -def test_generate_and_update_preserve_distinct_public_semantics(tmp_path, capsys): - generate_repo = create_sample_repo(tmp_path / "generate-case") - update_repo = create_sample_repo(tmp_path / "update-case") - - manual_agents = "# AGENTS.md\n\n## Team Notes\nKeep this manual section.\n" - write(generate_repo, "AGENTS.md", manual_agents) - write(update_repo, "AGENTS.md", manual_agents) - - generate_exit_code = main(["generate", str(generate_repo)]) - - assert generate_exit_code == 0 - generated = capsys.readouterr().out - assert "## Team Notes\n" not in generated - - update_exit_code = main(["update", str(update_repo)]) - - assert update_exit_code == 0 - updated_text = (update_repo / "AGENTS.md").read_text() - assert "## Team Notes\n\nKeep this manual section.\n" in updated_text diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py deleted file mode 100644 index ac128e2..0000000 --- a/tests/test_cli_entrypoint.py +++ /dev/null @@ -1,78 +0,0 @@ -import subprocess -import sys - -from test_support import ROOT, create_sample_repo - -from agentskill.lib import cli_entrypoint - - -def test_run_command_main_passes_lang_filter_and_pretty(monkeypatch): - captured = {} - - def fake_run_and_output( - command_fn, - *, - repo: str, - pretty: bool = False, - out: str | None = None, - script_name: str, - extra_kwargs: dict | None = None, - ) -> int: - captured["command_fn"] = command_fn - captured["repo"] = repo - captured["pretty"] = pretty - captured["out"] = out - captured["script_name"] = script_name - captured["extra_kwargs"] = extra_kwargs - return 7 - - monkeypatch.setattr(cli_entrypoint, "run_and_output", fake_run_and_output) - command_fn = object() - - exit_code = cli_entrypoint.run_command_main( - argv=["sample-repo", "--lang", "python", "--pretty"], - description="demo", - command_fn=command_fn, - script_name="scan", - supports_lang=True, - ) - - assert exit_code == 7 - assert captured == { - "command_fn": command_fn, - "repo": "sample-repo", - "pretty": True, - "out": None, - "script_name": "scan", - "extra_kwargs": {"lang_filter": "python"}, - } - - -def test_run_command_main_rejects_lang_when_command_does_not_support_it(capsys): - try: - cli_entrypoint.run_command_main( - argv=["sample-repo", "--lang", "python"], - description="demo", - command_fn=object(), - script_name="config", - ) - raise AssertionError( - "--lang should be rejected for non-language-aware commands" - ) - except SystemExit as exc: - assert exc.code == 2 - - assert "unrecognized arguments: --lang python" in capsys.readouterr().err - - -def test_measure_wrapper_still_executes_directly(tmp_path): - repo = create_sample_repo(tmp_path) - - completed = subprocess.run( - [sys.executable, str(ROOT / "scripts" / "measure.py"), str(repo), "--pretty"], - capture_output=True, - text=True, - check=True, - ) - - assert '"python"' in completed.stdout diff --git a/tests/test_common.py b/tests/test_common.py deleted file mode 100644 index 813eacc..0000000 --- a/tests/test_common.py +++ /dev/null @@ -1,100 +0,0 @@ -from pathlib import Path - -from agentskill.common.constants import MAX_FILE_BYTES, should_skip_dir -from agentskill.common.fs import count_lines, read_text, validate_repo -from agentskill.common.walk import walk_repo - - -def test_fs_helpers_handle_existing_and_missing_files(tmp_path): - path = tmp_path / "sample.txt" - path.write_text("a\nb\nc\n") - - assert count_lines(path) == 3 - assert read_text(path, max_bytes=3) == "a\nb" - assert count_lines(tmp_path / "missing.txt") == 0 - assert read_text(tmp_path / "missing.txt") == "" - - -def test_read_text_uses_real_byte_limits_and_tolerates_binary_data(tmp_path): - path = tmp_path / "sample.txt" - path.write_bytes(b"abc\xffdef\n") - - assert read_text(path) == "abcdef\n" - assert read_text(path, max_bytes=4) == "abc" - - large_path = tmp_path / "large.txt" - large_path.write_text("x" * (MAX_FILE_BYTES + 10)) - - assert len(read_text(large_path)) == MAX_FILE_BYTES - - -def test_validate_repo_accepts_directories_and_rejects_invalid_paths(tmp_path): - repo = validate_repo(str(tmp_path)) - - assert repo == tmp_path.resolve() - assert isinstance(repo, Path) - - missing = tmp_path / "missing" - - try: - validate_repo(str(missing)) - raise AssertionError("validate_repo should reject missing paths") - except ValueError as exc: - assert str(exc) == f"path does not exist: {missing}" - - file_path = tmp_path / "sample.txt" - file_path.write_text("hello\n") - - try: - validate_repo(str(file_path)) - raise AssertionError("validate_repo should reject file paths") - except ValueError as exc: - assert str(exc) == f"not a directory: {file_path}" - - -def test_should_skip_dir_covers_hidden_known_and_normal_names(): - assert should_skip_dir(".git") is True - assert should_skip_dir("node_modules") is True - assert should_skip_dir("src") is False - - -def test_walk_repo_skips_hidden_dirs_orders_paths_and_tracks_limits(tmp_path): - (tmp_path / ".git").mkdir() - (tmp_path / ".git" / "config").write_text("[core]\n") - (tmp_path / "node_modules").mkdir() - (tmp_path / "node_modules" / "lib.js").write_text("alert(1)\n") - (tmp_path / "pkg").mkdir() - (tmp_path / "pkg" / "b.py").write_text("print('b')\n") - (tmp_path / "pkg" / "a.py").write_text("print('a')\n") - (tmp_path / "root.py").write_text("print('root')\n") - - paths, stats = walk_repo(tmp_path) - - assert [str(path.relative_to(tmp_path)) for path in paths] == [ - "pkg/a.py", - "pkg/b.py", - "root.py", - ] - - assert stats.files_seen == 3 - assert stats.files_yielded == 3 - assert stats.hit_max_files is False - assert stats.oversize_files == 0 - - -def test_walk_repo_honors_max_files_and_marks_oversize_files(tmp_path): - (tmp_path / "a.py").write_text("a\n") - (tmp_path / "b.py").write_text("b\n") - (tmp_path / "big.py").write_text("x" * 20) - - paths, stats = walk_repo(tmp_path, max_files=2, max_file_bytes=10) - - assert [path.name for path in paths] == ["a.py", "b.py"] - assert stats.files_seen == 2 - assert stats.files_yielded == 2 - assert stats.hit_max_files is True - - paths, stats = walk_repo(tmp_path, max_files=10, max_file_bytes=10) - - assert [path.name for path in paths] == ["a.py", "b.py", "big.py"] - assert stats.oversize_files == 1 diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index a9bdfa8..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,499 +0,0 @@ -from pathlib import Path - -from test_support import create_repo, create_sample_repo - -from agentskill.commands.config import ( - MAX_CONFIG_READ_BYTES, - _detect_python_linter, - _detect_python_type_checker, - _detect_typescript, - _parse_by_extension, - _parse_editorconfig, - _parse_editorconfig_for_lang, - _parse_ini_section, - _read, - detect, -) -from agentskill.lib.parsers import load_toml_safe, load_yaml_safe - - -def test_config_detects_python_tooling(tmp_path): - repo = create_sample_repo(tmp_path) - result = detect(str(repo)) - - assert "python" in result - assert result["python"]["linter"]["name"] == "ruff" - assert "editorconfig" in result - - -def test_config_parsers_cover_toml_yaml_ini_and_editorconfig(tmp_path): - parsed_toml = load_toml_safe( - '[tool.demo]\nenabled = true\nnames = [\n "a",\n "b",\n]\n' - ) - - parsed_yaml = load_yaml_safe("tool:\n enabled: true\n count: 3\n") - parsed_ini = _parse_ini_section("[flake8]\nmax-line-length = 88\n", "[flake8]") - - ec_path = tmp_path / ".editorconfig" - ec_path.write_text("[*]\nindent_style = space\n[*.py]\nindent_size = 4\n") - sections = _parse_editorconfig(ec_path) - - assert parsed_toml["tool"]["demo"]["enabled"] is True - assert parsed_toml["tool"]["demo"]["names"] == ["a", "b"] - assert parsed_yaml["tool"]["enabled"] is True - assert parsed_ini == {"max-line-length": "88"} - - assert _parse_editorconfig_for_lang(sections, "python") == { - "indent_style": "space", - "indent_size": "4", - } - - -def test_config_temp_file_parsers_handle_empty_and_extension_cases(tmp_path): - assert _parse_by_extension("", ".prettierrc") == {} - assert _parse_by_extension("", ".prettierrc.yaml") == {} - assert _parse_by_extension("", "pyproject.toml") == {} - assert _parse_by_extension('{"semi": false}', ".prettierrc") == {"semi": False} - assert _parse_by_extension('{"strict": true}', "tsconfig.json") == {"strict": True} - assert _parse_by_extension("semi: false\n", ".prettierrc.yaml") == {"semi": False} - assert _parse_by_extension("answer = 42\n", "demo.toml") == {"answer": 42} - - editorconfig = tmp_path / ".editorconfig" - editorconfig.write_text("") - - assert _parse_editorconfig(editorconfig) == {} - - -def test_config_read_truncates_large_temp_files(tmp_path): - path = tmp_path / "huge.toml" - path.write_text("x" * (MAX_CONFIG_READ_BYTES + 100)) - - content = _read(path) - - assert isinstance(content, str) - assert len(content) == MAX_CONFIG_READ_BYTES - - -def test_config_editorconfig_language_section_overrides_global(tmp_path): - path = tmp_path / ".editorconfig" - path.write_text( - "[*]\nindent_style = tab\nindent_size = 8\n" - "[*.py]\nindent_style = space\n" - "[*.ts]\nindent_size = 2\n" - ) - - sections = _parse_editorconfig(path) - - assert _parse_editorconfig_for_lang(sections, "python") == { - "indent_style": "space", - "indent_size": "8", - } - assert _parse_editorconfig_for_lang(sections, "typescript") == { - "indent_style": "tab", - "indent_size": "2", - } - - -def test_config_temp_file_detectors_cover_package_and_python_ini_sources(tmp_path): - repo = create_repo( - tmp_path / "pkg_prettier", - { - "package.json": '{"prettier":{"semi":false,"tabWidth":2}}\n', - }, - name="pkg_prettier", - ) - - ts = _detect_typescript(repo) - - assert ts["formatter"] == { - "name": "prettier", - "config_file": "package.json", - "settings": {"semi": False, "tabWidth": 2}, - } - - setup_repo = create_repo( - tmp_path / "setup_cfg", - { - "setup.cfg": "[flake8]\nmax-line-length = 99\nextend-ignore = E203\n", - }, - name="setup_cfg", - ) - - assert _detect_python_linter(setup_repo, {}) == { - "name": "flake8", - "config_file": "setup.cfg", - "settings": {"max-line-length": "99", "extend-ignore": "E203"}, - } - - mypy_repo = create_repo( - tmp_path / "mypy_ini", - { - "mypy.ini": "[mypy]\npython_version = 3.11\n", - }, - name="mypy_ini", - ) - - assert _detect_python_type_checker(mypy_repo, {}) == { - "name": "mypy", - "config_file": "mypy.ini", - "settings": {"python_version": "3.11"}, - } - - hidden_mypy_repo = create_repo( - tmp_path / "dot_mypy_ini", - { - ".mypy.ini": "[mypy]\nwarn_unused_ignores = True\n", - }, - name="dot_mypy_ini", - ) - - assert _detect_python_type_checker(hidden_mypy_repo, {}) == { - "name": "mypy", - "config_file": ".mypy.ini", - "settings": {"warn_unused_ignores": "True"}, - } - - -def test_config_detects_typescript_go_and_rust_tooling(tmp_path): - repo = create_repo( - tmp_path, - { - ".prettierrc.json": '{"semi": false}\n', - ".eslintrc.yml": "rules:\n semi: off\n", - "tsconfig.json": '{"compilerOptions":{"strict":true}}\n', - "go.mod": "module example.com/demo\n", - ".golangci.yml": "run:\n timeout: 2m\n", - "Cargo.toml": '[package]\nname = "demo"\n', - "rustfmt.toml": "max_width = 100\n", - "clippy.toml": 'msrv = "1.70"\n', - }, - ) - - result = detect(str(repo)) - - assert result["typescript"]["formatter"]["name"] == "prettier" - assert result["typescript"]["linter"]["name"] == "eslint" - assert result["typescript"]["type_checker"]["name"] == "tsc" - assert result["go"]["formatter"]["name"] == "gofmt" - assert result["go"]["linter"]["name"] == "golangci-lint" - assert result["rust"]["formatter"]["name"] == "rustfmt" - assert result["rust"]["linter"]["name"] == "clippy" - - -def test_config_keeps_javascript_only_repos_under_javascript_key(tmp_path): - repo = create_repo( - tmp_path, - { - "package.json": '{"prettier":{"semi":true}}\n', - "src/index.js": "export const answer = 42;\n", - "src/index.test.js": "test('answer', () => expect(42).toBe(42));\n", - }, - ) - - result = detect(str(repo)) - - assert "javascript" in result - assert "typescript" not in result - assert result["javascript"]["formatter"]["name"] == "prettier" - - -def test_config_detects_java_and_kotlin_project_markers(tmp_path): - repo = create_repo( - tmp_path, - { - "pom.xml": "\n", - "build.gradle.kts": "plugins {}\n", - "settings.gradle.kts": 'rootProject.name = "demo"\n', - "src/main/java/com/acme/App.java": "package com.acme;\nclass App {}\n", - "src/test/java/com/acme/AppTest.java": "class AppTest {}\n", - "src/main/kotlin/com/acme/Main.kt": "package com.acme\nfun main() {}\n", - "src/test/kotlin/com/acme/MainTest.kt": "class MainTest\n", - }, - ) - - result = detect(str(repo)) - - assert result["java"]["build_tool"] == "maven" - assert "pom.xml" in result["java"]["project_markers"] - assert "src/main/java" in result["java"]["project_markers"] - assert "src/test/java" in result["java"]["project_markers"] - - assert result["kotlin"]["build_tool"] == "gradle" - assert "build.gradle.kts" in result["kotlin"]["project_markers"] - assert "settings.gradle.kts" in result["kotlin"]["project_markers"] - assert "src/main/kotlin" in result["kotlin"]["project_markers"] - assert "src/test/kotlin" in result["kotlin"]["project_markers"] - - -def test_config_detects_csharp_and_c_family_project_markers(tmp_path): - repo = create_repo( - tmp_path, - { - "project.sln": "\n", - "src/App.cs": "public class App {}\n", - "src/App.csproj": "\n", - "Directory.Build.props": "\n", - "CMakeLists.txt": "project(demo)\n", - "Makefile": "all:\n\tcc main.c\n", - "toolchain.cmake": "set(CMAKE_C_STANDARD 11)\n", - "src/main.c": "int main(void) { return 0; }\n", - "src/app.cpp": "int main() { return 0; }\n", - }, - ) - - result = detect(str(repo)) - - assert result["csharp"]["build_tool"] == "msbuild" - assert "project.sln" in result["csharp"]["project_markers"] - assert "App.csproj" in result["csharp"]["project_markers"] - assert "Directory.Build.props" in result["csharp"]["project_markers"] - - assert result["c"]["build_tool"] == "cmake" - assert "CMakeLists.txt" in result["c"]["project_markers"] - assert "Makefile" in result["c"]["project_markers"] - assert "toolchain.cmake" in result["c"]["project_markers"] - - assert result["cpp"]["build_tool"] == "cmake" - assert "CMakeLists.txt" in result["cpp"]["project_markers"] - - -def test_config_detects_ruby_and_php_project_markers(tmp_path): - repo = create_repo( - tmp_path, - { - "Gemfile": 'source "https://rubygems.org"\n', - "Gemfile.lock": "GEM\n", - "demo.gemspec": "Gem::Specification.new do |s| end\n", - "composer.json": ( - '{"autoload":{"psr-4":{"App\\\\":"src/"}},"require-dev":{"phpunit/phpunit":"^10"}}\n' - ), - "composer.lock": "{}\n", - "lib/user_service.rb": "class UserService\nend\n", - "src/Service/UserService.php": " Path: - return EXAMPLES_DIR / name - - -def _assert_no_error_payload(result): - assert isinstance(result, dict) - assert "error" not in result - - -def _symbol_and_test_lang(example_name: str) -> str: - return "typescript" if example_name == "javascript" else example_name - - -def test_examples_include_every_supported_language_directory(): - dirs = {path.name for path in EXAMPLES_DIR.iterdir() if path.is_dir()} - - assert dirs >= SUPPORTED_EXAMPLE_DIRS - assert "mixed" in dirs - - -def test_examples_readme_exists(): - assert (EXAMPLES_DIR / "README.md").exists() - - -def test_all_analyzers_run_on_every_language_example(): - for language in sorted(SUPPORTED_EXAMPLE_DIRS): - repo = _example_path(language) - - scan_result = scan(str(repo)) - _assert_no_error_payload(scan_result) - assert language in scan_result["summary"]["by_language"] or ( - language == "objectivec" - and "objectivec" in scan_result["summary"]["by_language"] - ) - - measure_result = measure(str(repo)) - _assert_no_error_payload(measure_result) - assert language in measure_result or ( - language == "objectivec" and "objectivec" in measure_result - ) - - graph_result = build_graph(str(repo)) - _assert_no_error_payload(graph_result) - assert language in graph_result - - symbols_result = extract_symbols(str(repo)) - _assert_no_error_payload(symbols_result) - assert _symbol_and_test_lang(language) in symbols_result - - tests_result = analyze_tests(str(repo)) - _assert_no_error_payload(tests_result) - assert _symbol_and_test_lang(language) in tests_result - - -def test_language_examples_produce_expected_behavioral_signals(): - py_graph = build_graph(str(_example_path("python"))) - assert {"from": "src.app", "to": "src.util", "line": 1} in py_graph["python"][ - "edges" - ] - - js_graph = build_graph(str(_example_path("javascript"))) - assert {"from": "src/index.js", "to": "src/util.js", "line": 1} in js_graph[ - "javascript" - ]["edges"] - - ts_graph = build_graph(str(_example_path("typescript"))) - assert {"from": "src/index.ts", "to": "src/user.ts", "line": 1} in ts_graph[ - "typescript" - ]["edges"] - - go_graph = build_graph(str(_example_path("go"))) - assert {"from": "cmd/app", "to": "internal/service", "line": 3} in go_graph["go"][ - "edges" - ] - - rust_graph = build_graph(str(_example_path("rust"))) - assert {"from": "src/lib.rs", "to": "src/parser.rs", "line": 1} in rust_graph[ - "rust" - ]["edges"] - - java_graph = build_graph(str(_example_path("java"))) - assert { - "from": "src/main/java/com/example/App.java", - "to": "src/main/java/com/example/service/UserService.java", - "line": 3, - } in java_graph["java"]["edges"] - - kotlin_graph = build_graph(str(_example_path("kotlin"))) - assert { - "from": "src/main/kotlin/com/example/App.kt", - "to": "src/main/kotlin/com/example/service/UserService.kt", - "line": 3, - } in kotlin_graph["kotlin"]["edges"] - - csharp_graph = build_graph(str(_example_path("csharp"))) - assert { - "from": "src/App.cs", - "to": "src/Core/UserService.cs", - "line": 1, - } in csharp_graph["csharp"]["edges"] - - c_graph = build_graph(str(_example_path("c"))) - assert {"from": "src/main.c", "to": "src/util.h", "line": 1} in c_graph["c"][ - "edges" - ] - - cpp_graph = build_graph(str(_example_path("cpp"))) - assert { - "from": "src/app.cpp", - "to": "include/example/service.hpp", - "line": 1, - } in cpp_graph["cpp"]["edges"] - - ruby_graph = build_graph(str(_example_path("ruby"))) - assert { - "from": "lib/example/service.rb", - "to": "lib/example/helper.rb", - "line": 1, - } in ruby_graph["ruby"]["edges"] - - php_graph = build_graph(str(_example_path("php"))) - assert { - "from": "src/Service/UserService.php", - "to": "src/Repository/UserRepository.php", - "line": 4, - } in php_graph["php"]["edges"] - - swift_symbols = extract_symbols(str(_example_path("swift"))) - assert swift_symbols["swift"]["structs"]["total"] >= 1 - - objc_graph = build_graph(str(_example_path("objectivec"))) - assert { - "from": "Sources/UserService.m", - "to": "Sources/UserService.h", - "line": 1, - } in objc_graph["objectivec"]["edges"] - - bash_graph = build_graph(str(_example_path("bash"))) - assert { - "from": "scripts/deploy.sh", - "to": "scripts/lib/common.sh", - "line": 3, - } in bash_graph["bash"]["edges"] - - -def test_language_examples_expose_test_mappings(): - python_tests = analyze_tests(str(_example_path("python"))) - assert python_tests["python"]["coverage_shape"]["mapped"] - - javascript_tests = analyze_tests(str(_example_path("javascript"))) - assert javascript_tests["typescript"]["test_files"] >= 1 - - typescript_tests = analyze_tests(str(_example_path("typescript"))) - assert typescript_tests["typescript"]["coverage_shape"]["mapped"] - - go_tests = analyze_tests(str(_example_path("go"))) - assert go_tests["go"]["coverage_shape"]["mapped"] - - rust_tests = analyze_tests(str(_example_path("rust"))) - assert rust_tests["rust"]["test_files"] >= 1 - - java_tests = analyze_tests(str(_example_path("java"))) - assert java_tests["java"]["coverage_shape"]["mapped"] - - kotlin_tests = analyze_tests(str(_example_path("kotlin"))) - assert kotlin_tests["kotlin"]["coverage_shape"]["mapped"] - - csharp_tests = analyze_tests(str(_example_path("csharp"))) - assert csharp_tests["csharp"]["coverage_shape"]["mapped"] - - c_tests = analyze_tests(str(_example_path("c"))) - assert c_tests["c"]["coverage_shape"]["mapped"] - - cpp_tests = analyze_tests(str(_example_path("cpp"))) - assert cpp_tests["cpp"]["coverage_shape"]["mapped"] - - ruby_tests = analyze_tests(str(_example_path("ruby"))) - assert ruby_tests["ruby"]["test_files"] >= 1 - - php_tests = analyze_tests(str(_example_path("php"))) - assert php_tests["php"]["coverage_shape"]["mapped"] - - swift_tests = analyze_tests(str(_example_path("swift"))) - assert swift_tests["swift"]["coverage_shape"]["mapped"] - - objc_tests = analyze_tests(str(_example_path("objectivec"))) - assert objc_tests["objectivec"]["coverage_shape"]["mapped"] - - bash_tests = analyze_tests(str(_example_path("bash"))) - assert bash_tests["bash"]["test_files"] >= 1 - - -def test_mixed_example_validates_multi_language_behavior(): - repo = _example_path("mixed") - - scan_result = scan(str(repo)) - _assert_no_error_payload(scan_result) - langs = set(scan_result["summary"]["by_language"]) - assert {"typescript", "go", "bash"} <= langs - - measure_result = measure(str(repo)) - _assert_no_error_payload(measure_result) - assert {"typescript", "go", "bash"} <= set(measure_result) - - graph_result = build_graph(str(repo)) - _assert_no_error_payload(graph_result) - assert "typescript" in graph_result - assert "go" in graph_result - assert "bash" in graph_result - - symbols_result = extract_symbols(str(repo)) - _assert_no_error_payload(symbols_result) - assert "typescript" in symbols_result - assert "go" in symbols_result - assert "bash" in symbols_result - - tests_result = analyze_tests(str(repo)) - _assert_no_error_payload(tests_result) - assert "typescript" in tests_result - assert "go" in tests_result - assert "bash" in tests_result - - config_result = detect(str(repo)) - _assert_no_error_payload(config_result) - assert "typescript" in config_result - assert "go" in config_result diff --git a/tests/test_generate_cli.py b/tests/test_generate_cli.py deleted file mode 100644 index 6dbb8ec..0000000 --- a/tests/test_generate_cli.py +++ /dev/null @@ -1,323 +0,0 @@ -import builtins -from pathlib import Path - -from test_support import ( - commit_all, - create_repo, - create_sample_repo, - init_git_repo, - write, -) - -from agentskill.main import main - - -def test_generate_prints_markdown_to_stdout_without_writing_repo_file(tmp_path, capsys): - repo = create_sample_repo(tmp_path) - exit_code = main(["generate", str(repo)]) - - assert exit_code == 0 - captured = capsys.readouterr() - assert captured.out.startswith("# AGENTS.md\n\n## 1. Overview\n") - assert "\n\n## 2. Repository Structure\n" in captured.out - assert "## 5. Commands and Workflows\n" in captured.out - assert "## 6. Code Formatting\n\n### Python\n" in captured.out - assert not (repo / "AGENTS.md").exists() - assert captured.err == "" - - -def test_generate_writes_markdown_to_explicit_output_path( - tmp_path, monkeypatch, capsys -): - repo = create_sample_repo(tmp_path) - monkeypatch.chdir(tmp_path) - out_path = Path("generated/AGENTS.md") - - exit_code = main(["generate", str(repo), "--out", str(out_path)]) - - assert exit_code == 0 - assert out_path.exists() - assert out_path.read_text().startswith("# AGENTS.md\n\n## 1. Overview\n") - assert capsys.readouterr().out == "" - assert not (repo / "AGENTS.md").exists() - - -def test_generate_ignores_existing_agents_file_and_does_not_merge(tmp_path, capsys): - repo = create_sample_repo(tmp_path) - write( - repo, - "AGENTS.md", - "# AGENTS\n\n## Team Notes\nKeep this manual section.\n", - ) - - exit_code = main(["generate", str(repo)]) - - assert exit_code == 0 - generated = capsys.readouterr().out - assert generated.startswith("# AGENTS.md\n\n## 1. Overview\n") - assert "Team Notes" not in generated - - -def test_generate_includes_reference_metadata_block(tmp_path, capsys): - repo = create_sample_repo(tmp_path / "target") - reference = create_repo(tmp_path, name="reference") - write(reference, "AGENTS.md", "# AGENTS\n\n## 12. Testing\nUse pytest.\n") - - exit_code = main(["generate", str(repo), "--reference", str(reference)]) - - assert exit_code == 0 - generated = capsys.readouterr().out - assert generated.startswith("# AGENTS.md\n\n") - json_body = block.split("\n", 1)[1].rsplit("\n-->", 1)[0] - parsed = json.loads(json_body) - assert "agentskill_version" in parsed - assert "references" in parsed - - -def test_render_metadata_block_roundtrip(): - doc = _doc( - source=_src(kind="remote", value="https://github.com/org/repo.git"), - commit_sha="deadbeef", - ) - meta = build_reference_metadata([doc], "0.5.0") - block = render_reference_metadata_block(meta) - json_body = block.split("\n", 1)[1].rsplit("\n-->", 1)[0] - parsed = json.loads(json_body) - - assert parsed["agentskill_version"] == "0.5.0" - assert parsed["references"][0]["commit_sha"] == "deadbeef" - - -def test_successful_reference_documents(): - src_a = _src(value="a") - src_b = _src(value="b") - doc_a = _doc(source=src_a) - ok = ReferenceLoadResult(source=src_a, document=doc_a) - fail = ReferenceLoadResult(source=src_b, error="not found") - docs = successful_reference_documents([ok, fail]) - - assert len(docs) == 1 - assert docs[0].source.value == "a" - - -def test_initialize_empty_target_reference_derived(): - target = _analysis(scan={"summary": {"total_files": 0}, "tree": []}) - doc = _doc(content="## Testing\n\nUse pytest.") - result = initialize_from_references(target, [doc]) - - assert result.is_reference_derived is True - assert result.adapted_references - assert result.metadata is not None - assert result.usable_reference_count == 1 - - -def test_initialize_non_empty_target_not_reference_derived(): - target = _analysis( - scan={ - "summary": {"total_files": 3, "languages": ["python"]}, - "tree": [{"path": "main.py"}], - } - ) - doc = _doc(content="## Testing\n\nUse pytest.") - result = initialize_from_references(target, [doc]) - - assert result.is_reference_derived is False - assert result.metadata is not None - assert result.adapted_references - - -def test_initialize_multiple_references_preserve_order(): - target = _analysis(scan={"summary": {"total_files": 0}, "tree": []}) - docs = [ - _doc(source=_src(value="a"), content="## A\n\nUse A."), - _doc(source=_src(value="b"), content="## B\n\nUse B."), - _doc(source=_src(value="c"), content="## C\n\nUse C."), - ] - result = initialize_from_references(target, docs) - - assert [r.source.value for r in result.adapted_references] == ["a", "b", "c"] - meta_refs = result.metadata.to_dict()["references"] - assert [r["value"] for r in meta_refs] == ["a", "b", "c"] - - -def test_initialize_includes_questions(): - target = _analysis(scan={"summary": {"total_files": 0}, "tree": []}) - doc = _doc(content="## Testing\n\nUse pytest for tests.") - result = initialize_from_references(target, [doc]) - - assert isinstance(result.questions, list) - - -def test_initialize_no_documents_warning(): - target = _analysis(scan={"summary": {"total_files": 0}, "tree": []}) - result = initialize_from_references(target, []) - - assert result.usable_reference_count == 0 - assert "no reference documents provided" in result.warnings - - -def test_initialize_agentskill_version_in_metadata(): - target = _analysis(scan={"summary": {"total_files": 0}, "tree": []}) - doc = _doc() - result = initialize_from_references(target, [doc], agentskill_version="1.2.3") - - assert result.metadata.agentskill_version == "1.2.3" - - -def test_initialize_default_agentskill_version(): - target = _analysis(scan={"summary": {"total_files": 0}, "tree": []}) - doc = _doc() - result = initialize_from_references(target, [doc]) - - assert result.metadata.agentskill_version == AGENTSKILL_VERSION diff --git a/tests/test_reference_questions.py b/tests/test_reference_questions.py deleted file mode 100644 index 484e6ae..0000000 --- a/tests/test_reference_questions.py +++ /dev/null @@ -1,336 +0,0 @@ -"""Tests for reference question generation.""" - -from agentskill.lib.reference_adaptation import ( - AdaptedConvention, - ReferenceAdaptationResult, - ReferenceSection, -) -from agentskill.lib.reference_questions import ( - QUESTION_CATEGORY_CONFLICT, - QUESTION_CATEGORY_DIRECTORY_STRUCTURE, - QUESTION_CATEGORY_FORMATTER, - QUESTION_CATEGORY_LINTER, - QUESTION_CATEGORY_TESTING, - QUESTION_CATEGORY_UNKNOWN, - ReferenceQuestion, - generate_reference_questions, -) -from agentskill.lib.references import ReferenceSource - - -def _src(kind: str = "local", value: str = "../ref") -> ReferenceSource: - return ReferenceSource(kind=kind, value=value) - - -def _section( - heading: str = "Testing", body: str = "Use pytest.", level: int = 2 -) -> ReferenceSection: - return ReferenceSection(heading=heading, body=body, level=level) - - -def _conv( - heading: str = "Testing", - body: str = "Use pytest.", - category: str = "testing", - status: str = "uncertain", - reason: str = "missing config", -) -> AdaptedConvention: - return AdaptedConvention( - section=_section(heading, body), - category=category, - status=status, - reason=reason, - ) - - -def _result( - conventions: list[AdaptedConvention] | None = None, - source: ReferenceSource | None = None, -) -> ReferenceAdaptationResult: - if conventions is None: - conventions = [_conv()] - if source is None: - source = _src() - - return ReferenceAdaptationResult(source=source, conventions=conventions) - - -def _analysis(**kwargs) -> dict: - return dict(kwargs) - - -def test_question_serialization_required_fields(): - q = ReferenceQuestion( - section="Testing", - question="Use pytest?", - reason="missing config", - category="testing", - ) - d = q.to_dict() - - assert d["section"] == "Testing" - assert d["question"] == "Use pytest?" - assert d["reason"] == "missing config" - assert d["category"] == "testing" - assert d["blocking"] is False - assert "source" not in d - assert "options" not in d - - -def test_question_serialization_optional_fields(): - q = ReferenceQuestion( - section="Testing", - question="Use pytest?", - reason="missing config", - category="testing", - source=_src(), - blocking=True, - options=["pytest", "omit"], - ) - d = q.to_dict() - - assert d["source"] == {"kind": "local", "value": "../ref"} - assert d["blocking"] is True - assert d["options"] == ["pytest", "omit"] - - -def test_uncertain_testing_question(): - conv = _conv( - heading="Testing", - body="Use pytest for tests.", - category="testing", - status="uncertain", - reason="target analysis missing config data", - ) - result = _result(conventions=[conv]) - questions = generate_reference_questions([result]) - - assert len(questions) == 1 - q = questions[0] - - assert q.category == QUESTION_CATEGORY_TESTING - assert "pytest" in q.question - assert q.blocking is False - assert q.options is not None - assert "pytest" in q.options - - -def test_uncertain_formatter_question(): - conv = _conv( - heading="Formatting", - body="Use black for formatting.", - category="formatter", - status="uncertain", - reason="target analysis missing config data", - ) - result = _result(conventions=[conv]) - questions = generate_reference_questions([result]) - - assert len(questions) == 1 - assert questions[0].category == QUESTION_CATEGORY_FORMATTER - assert "black" in questions[0].question - - -def test_uncertain_linter_question(): - conv = _conv( - heading="Linting", - body="Use ruff for linting.", - category="linter", - status="uncertain", - reason="target analysis missing config data", - ) - result = _result(conventions=[conv]) - questions = generate_reference_questions([result]) - - assert len(questions) == 1 - assert questions[0].category == QUESTION_CATEGORY_LINTER - assert "ruff" in questions[0].question - - -def test_uncertain_directory_structure_question(): - conv = _conv( - heading="Structure", - body="Source code lives in src/.", - category="directory_structure", - status="uncertain", - reason="no directory paths referenced", - ) - result = _result(conventions=[conv]) - questions = generate_reference_questions([result]) - - assert len(questions) == 1 - assert questions[0].category == QUESTION_CATEGORY_DIRECTORY_STRUCTURE - - -def test_uncertain_unknown_section_question(): - conv = _conv( - heading="Philosophy", - body="Be pragmatic.", - category="unknown", - status="uncertain", - reason="no recognizable language, tool, or directory keywords", - ) - result = _result(conventions=[conv]) - questions = generate_reference_questions([result]) - - assert len(questions) == 1 - assert questions[0].category == QUESTION_CATEGORY_UNKNOWN - assert "Philosophy" in questions[0].question - - -def test_mismatched_relevant_tool_question(): - conv = _conv( - heading="Formatting", - body="Use black for formatting.", - category="formatter", - status="mismatched", - reason="tool black mentioned but not detected in target", - ) - target = _analysis( - scan={"summary": {"languages": ["python"]}}, - ) - result = _result(conventions=[conv]) - questions = generate_reference_questions([result], target_analysis=target) - - assert len(questions) == 1 - assert questions[0].category == QUESTION_CATEGORY_FORMATTER - assert "black" in questions[0].question - - -def test_mismatched_irrelevant_language_no_question(): - conv = _conv( - heading="Go", - body="Use gofmt for Go formatting.", - category="language", - status="mismatched", - reason="language go not found in target scan summary", - ) - target = _analysis( - scan={"summary": {"languages": ["python"]}}, - ) - result = _result(conventions=[conv]) - questions = generate_reference_questions([result], target_analysis=target) - - assert len(questions) == 0 - - -def test_mismatched_irrelevant_tool_no_question(): - conv = _conv( - heading="Formatting", - body="Use gofmt for Go formatting.", - category="formatter", - status="mismatched", - reason="tool gofmt mentioned but not detected in target", - ) - target = _analysis( - scan={"summary": {"languages": ["python"]}}, - ) - result = _result(conventions=[conv]) - questions = generate_reference_questions([result], target_analysis=target) - - assert len(questions) == 0 - - -def test_mismatched_directory_structure_question(): - conv = _conv( - heading="Structure", - body="Source code lives in frontend/.", - category="directory_structure", - status="mismatched", - reason="referenced paths not found in target scan tree", - ) - result = _result(conventions=[conv]) - questions = generate_reference_questions([result]) - - assert len(questions) == 1 - assert questions[0].category == QUESTION_CATEGORY_DIRECTORY_STRUCTURE - - -def test_applicable_convention_no_question(): - conv = _conv( - heading="Testing", - body="Use pytest.", - category="testing", - status="applicable", - reason="pytest detected in target analysis", - ) - result = _result(conventions=[conv]) - questions = generate_reference_questions([result]) - - assert len(questions) == 0 - - -def test_conflict_question(): - conv_a = _conv( - heading="Testing", - body="Use pytest.", - category="testing", - status="uncertain", - reason="missing config", - ) - conv_b = _conv( - heading="Testing", - body="Use unittest.", - category="testing", - status="uncertain", - reason="missing config", - ) - src_a = _src(value="ref-a") - src_b = _src(value="ref-b") - result_a = _result(conventions=[conv_a], source=src_a) - result_b = _result(conventions=[conv_b], source=src_b) - questions = generate_reference_questions([result_a, result_b]) - - conflict = [q for q in questions if q.category == QUESTION_CATEGORY_CONFLICT] - - assert len(conflict) == 1 - assert "pytest" in conflict[0].question - assert "unittest" in conflict[0].question - assert conflict[0].options is not None - assert "pytest" in conflict[0].options - assert "unittest" in conflict[0].options - - -def test_deduplication(): - conv = _conv( - heading="Testing", - body="Use pytest.", - category="testing", - status="uncertain", - reason="missing config", - ) - result_a = _result(conventions=[conv], source=_src(value="ref-a")) - result_b = _result(conventions=[conv], source=_src(value="ref-b")) - questions = generate_reference_questions([result_a, result_b]) - - testing = [q for q in questions if q.category == QUESTION_CATEGORY_TESTING] - - assert len(testing) == 1 - - -def test_deterministic_order(): - conv_a = _conv( - heading="Testing", - body="Use pytest.", - category="testing", - status="uncertain", - reason="missing config", - ) - conv_b = _conv( - heading="Formatting", - body="Use black.", - category="formatter", - status="uncertain", - reason="missing config", - ) - result = _result(conventions=[conv_a, conv_b]) - questions_a = generate_reference_questions([result]) - questions_b = generate_reference_questions([result]) - - assert [q.section for q in questions_a] == [q.section for q in questions_b] - - -def test_no_adaptations_returns_empty(): - questions = generate_reference_questions([]) - - assert questions == [] diff --git a/tests/test_references.py b/tests/test_references.py deleted file mode 100644 index 305347e..0000000 --- a/tests/test_references.py +++ /dev/null @@ -1,473 +0,0 @@ -from unittest import TestCase -from unittest.mock import patch - -from agentskill.lib.references import ( - ReferenceDocument, - ReferenceLoadResult, - ReferenceMetadata, - ReferenceSource, - load_local_reference, - load_local_references, - load_remote_reference, - load_remote_references, -) - - -def test_reference_source_valid_local(): - src = ReferenceSource(kind="local", value="../my-service") - assert src.kind == "local" - assert src.value == "../my-service" - assert src.label is None - - -def test_reference_source_valid_remote(): - src = ReferenceSource(kind="remote", value="https://github.com/org/repo.git") - assert src.kind == "remote" - - -def test_reference_source_with_label(): - src = ReferenceSource(kind="local", value="../svc", label="my service") - assert src.label == "my service" - - -def test_reference_source_rejects_empty_value(): - with TestCase().assertRaisesRegex(ValueError, "must not be empty"): - ReferenceSource(kind="local", value="") - - -def test_reference_source_rejects_unsupported_kind(): - with TestCase().assertRaisesRegex(ValueError, "unsupported"): - ReferenceSource(kind="inline", value="something") - - -def test_reference_source_to_dict_omits_label(): - d = ReferenceSource(kind="local", value="../svc").to_dict() - assert "label" not in d - assert d == {"kind": "local", "value": "../svc"} - - -def test_reference_source_to_dict_includes_label(): - d = ReferenceSource(kind="local", value="../svc", label="svc").to_dict() - assert d["label"] == "svc" - - -def test_reference_document_defaults(): - src = ReferenceSource(kind="local", value="../svc") - doc = ReferenceDocument(source=src, content="# AGENTS.md\n\nRules here.") - assert doc.source_path == "AGENTS.md" - assert doc.version is None - assert doc.commit_sha is None - - -def test_reference_document_to_dict_includes_optional_fields(): - src = ReferenceSource(kind="remote", value="https://github.com/org/repo.git") - doc = ReferenceDocument( - source=src, - content="content", - version="1.0", - commit_sha="abc123", - ) - d = doc.to_dict() - assert d["version"] == "1.0" - assert d["commit_sha"] == "abc123" - - -def test_reference_document_to_dict_omits_optional_fields(): - src = ReferenceSource(kind="local", value="../svc") - doc = ReferenceDocument(source=src, content="content") - d = doc.to_dict() - assert "version" not in d - assert "commit_sha" not in d - - -def test_reference_load_result_success(): - src = ReferenceSource(kind="local", value="../svc") - doc = ReferenceDocument(source=src, content="content") - result = ReferenceLoadResult(source=src, document=doc) - assert result.ok - assert result.error is None - - -def test_reference_load_result_failure(): - src = ReferenceSource(kind="local", value="../svc") - result = ReferenceLoadResult(source=src, error="not found") - assert not result.ok - assert result.document is None - - -def test_reference_load_result_rejects_both_set(): - src = ReferenceSource(kind="local", value="../svc") - doc = ReferenceDocument(source=src, content="content") - with TestCase().assertRaisesRegex(ValueError, "cannot have both"): - ReferenceLoadResult(source=src, document=doc, error="oops") - - -def test_reference_load_result_rejects_neither_set(): - src = ReferenceSource(kind="local", value="../svc") - with TestCase().assertRaisesRegex(ValueError, "must have either"): - ReferenceLoadResult(source=src) - - -def test_reference_load_result_to_dict_success(): - src = ReferenceSource(kind="local", value="../svc") - doc = ReferenceDocument(source=src, content="content") - d = ReferenceLoadResult(source=src, document=doc).to_dict() - assert "document" in d - assert "error" not in d - - -def test_reference_load_result_to_dict_failure(): - src = ReferenceSource(kind="local", value="../svc") - d = ReferenceLoadResult(source=src, error="not found").to_dict() - assert "error" in d - assert "document" not in d - - -def test_reference_metadata_serialization(): - meta = ReferenceMetadata( - agentskill_version="0.5.0", - sources=[ - {"kind": "local", "value": "../svc", "source_path": "AGENTS.md"}, - { - "kind": "remote", - "value": "https://github.com/org/repo.git", - "source_path": "AGENTS.md", - "commit_sha": "abc123", - }, - ], - ) - - d = meta.to_dict() - assert d["agentskill_version"] == "0.5.0" - assert len(d["references"]) == 2 - assert d["references"][0]["kind"] == "local" - assert d["references"][1]["commit_sha"] == "abc123" - - -def test_reference_metadata_preserves_source_ordering(): - meta = ReferenceMetadata( - agentskill_version="0.5.0", - sources=[ - {"kind": "local", "value": "../a"}, - {"kind": "local", "value": "../b"}, - {"kind": "local", "value": "../c"}, - ], - ) - - d = meta.to_dict() - assert [s["value"] for s in d["references"]] == ["../a", "../b", "../c"] - - -def test_reference_metadata_omits_absent_optional_fields(): - meta = ReferenceMetadata(agentskill_version="0.5.0") - d = meta.to_dict() - assert d["references"] == [] - - -def test_load_local_reference_success(tmp_path): - repo = tmp_path / "my-repo" - repo.mkdir() - (repo / "AGENTS.md").write_text("# Rules\n\nBe kind.") - - src = ReferenceSource(kind="local", value=str(repo)) - result = load_local_reference(src) - - assert result.ok - assert result.document is not None - assert result.document.content == "# Rules\n\nBe kind." - assert result.document.source_path == "AGENTS.md" - assert result.document.source is src - - -def test_load_local_reference_missing_path(tmp_path): - missing = tmp_path / "does-not-exist" - src = ReferenceSource(kind="local", value=str(missing)) - result = load_local_reference(src) - - assert not result.ok - assert result.document is None - assert result.error is not None - assert "does not exist" in result.error - - -def test_load_local_reference_path_is_file(tmp_path): - file_path = tmp_path / "not-a-dir" - file_path.write_text("hello") - - src = ReferenceSource(kind="local", value=str(file_path)) - result = load_local_reference(src) - - assert not result.ok - assert result.error is not None - assert "not a directory" in result.error - - -def test_load_local_reference_missing_agents_md(tmp_path): - repo = tmp_path / "empty-repo" - repo.mkdir() - - src = ReferenceSource(kind="local", value=str(repo)) - result = load_local_reference(src) - - assert not result.ok - assert result.error is not None - assert "AGENTS.md not found" in result.error - - -def test_load_local_reference_empty_agents_md(tmp_path): - repo = tmp_path / "repo" - repo.mkdir() - (repo / "AGENTS.md").write_text("") - - src = ReferenceSource(kind="local", value=str(repo)) - result = load_local_reference(src) - - assert not result.ok - assert result.error is not None - assert "empty" in result.error - - -def test_load_local_reference_whitespace_only_agents_md(tmp_path): - repo = tmp_path / "repo" - repo.mkdir() - (repo / "AGENTS.md").write_text(" \n\n ") - - src = ReferenceSource(kind="local", value=str(repo)) - result = load_local_reference(src) - - assert not result.ok - assert result.error is not None - assert "empty" in result.error - - -def test_load_local_reference_unsupported_kind(): - src = ReferenceSource(kind="remote", value="https://github.com/org/repo.git") - result = load_local_reference(src) - - assert not result.ok - assert result.error is not None - assert "unsupported local reference source kind" in result.error - - -def test_load_local_references_batch_preserves_order(tmp_path): - repo_a = tmp_path / "repo-a" - repo_a.mkdir() - (repo_a / "AGENTS.md").write_text("# A\n") - - repo_c = tmp_path / "repo-c" - repo_c.mkdir() - (repo_c / "AGENTS.md").write_text("# C\n") - - sources = [ - ReferenceSource(kind="local", value=str(repo_a)), - ReferenceSource(kind="local", value=str(tmp_path / "missing")), - ReferenceSource(kind="local", value=str(repo_c)), - ] - - results = load_local_references(sources) - - assert len(results) == 3 - assert results[0].ok - assert not results[1].ok - assert results[2].ok - assert results[0].document is not None - assert results[2].document is not None - assert results[0].document.content == "# A\n" - assert results[2].document.content == "# C\n" - - -def _mock_clone_success(tmp_dir, agents_content="# Rules\n", sha="abc123def"): - clone_dir = tmp_dir / "repo" - clone_dir.mkdir(parents=True) - (clone_dir / "AGENTS.md").write_text(agents_content) - return clone_dir - - -def test_load_remote_reference_success(tmp_path): - src = ReferenceSource(kind="remote", value="https://github.com/org/repo.git") - _mock_clone_success(tmp_path) - - def fake_run_git(cmd, cwd=None): - if "clone" in cmd: - return 0, "", "" - if "rev-parse" in cmd: - return 0, "abc123def\n", "" - return 1, "", "unknown command" - - with ( - patch("agentskill.lib.references._run_git", side_effect=fake_run_git), - patch("agentskill.lib.references.TemporaryDirectory") as mock_tmp, - ): - mock_tmp.return_value.__enter__ = lambda s: str(tmp_path) - mock_tmp.return_value.__exit__ = lambda s, *a: None - result = load_remote_reference(src) - - assert result.ok - assert result.document is not None - assert result.document.content == "# Rules\n" - assert result.document.source_path == "AGENTS.md" - assert result.document.commit_sha == "abc123def" - - -def test_load_remote_reference_clone_failure(tmp_path): - src = ReferenceSource(kind="remote", value="https://github.com/org/repo.git") - - def fake_run_git(cmd, cwd=None): - if "clone" in cmd: - return 1, "", "fatal: repository not found" - return 1, "", "" - - with ( - patch("agentskill.lib.references._run_git", side_effect=fake_run_git), - patch("agentskill.lib.references.TemporaryDirectory") as mock_tmp, - ): - mock_tmp.return_value.__enter__ = lambda s: str(tmp_path) - mock_tmp.return_value.__exit__ = lambda s, *a: None - result = load_remote_reference(src) - - assert not result.ok - assert result.error is not None - assert "failed to clone" in result.error - - -def test_load_remote_reference_clone_timeout(tmp_path): - src = ReferenceSource(kind="remote", value="https://github.com/org/repo.git") - - def fake_run_git(cmd, cwd=None): - return 1, "", "git command timed out after 60s" - - with ( - patch("agentskill.lib.references._run_git", side_effect=fake_run_git), - patch("agentskill.lib.references.TemporaryDirectory") as mock_tmp, - ): - mock_tmp.return_value.__enter__ = lambda s: str(tmp_path) - mock_tmp.return_value.__exit__ = lambda s, *a: None - result = load_remote_reference(src) - - assert not result.ok - assert result.error is not None - assert "failed to clone" in result.error - - -def test_load_remote_reference_missing_agents_md(tmp_path): - src = ReferenceSource(kind="remote", value="https://github.com/org/repo.git") - clone_dir = tmp_path / "repo" - clone_dir.mkdir(parents=True) - - def fake_run_git(cmd, cwd=None): - if "clone" in cmd: - return 0, "", "" - if "rev-parse" in cmd: - return 0, "abc123\n", "" - return 1, "", "" - - with ( - patch("agentskill.lib.references._run_git", side_effect=fake_run_git), - patch("agentskill.lib.references.TemporaryDirectory") as mock_tmp, - ): - mock_tmp.return_value.__enter__ = lambda s: str(tmp_path) - mock_tmp.return_value.__exit__ = lambda s, *a: None - result = load_remote_reference(src) - - assert not result.ok - assert result.error is not None - assert "AGENTS.md not found" in result.error - - -def test_load_remote_reference_empty_agents_md(tmp_path): - src = ReferenceSource(kind="remote", value="https://github.com/org/repo.git") - _mock_clone_success(tmp_path, agents_content="") - - def fake_run_git(cmd, cwd=None): - if "clone" in cmd: - return 0, "", "" - if "rev-parse" in cmd: - return 0, "abc123\n", "" - return 1, "", "" - - with ( - patch("agentskill.lib.references._run_git", side_effect=fake_run_git), - patch("agentskill.lib.references.TemporaryDirectory") as mock_tmp, - ): - mock_tmp.return_value.__enter__ = lambda s: str(tmp_path) - mock_tmp.return_value.__exit__ = lambda s, *a: None - result = load_remote_reference(src) - - assert not result.ok - assert result.error is not None - assert "empty" in result.error - - -def test_load_remote_reference_commit_sha_unavailable(tmp_path): - src = ReferenceSource(kind="remote", value="https://github.com/org/repo.git") - _mock_clone_success(tmp_path) - - def fake_run_git(cmd, cwd=None): - if "clone" in cmd: - return 0, "", "" - if "rev-parse" in cmd: - return 1, "", "not a git repo" - return 1, "", "" - - with ( - patch("agentskill.lib.references._run_git", side_effect=fake_run_git), - patch("agentskill.lib.references.TemporaryDirectory") as mock_tmp, - ): - mock_tmp.return_value.__enter__ = lambda s: str(tmp_path) - mock_tmp.return_value.__exit__ = lambda s, *a: None - result = load_remote_reference(src) - - assert result.ok - assert result.document is not None - assert result.document.commit_sha is None - - -def test_load_remote_reference_unsupported_kind(): - src = ReferenceSource(kind="local", value="../some-repo") - result = load_remote_reference(src) - - assert not result.ok - assert result.error is not None - assert "unsupported remote reference source kind" in result.error - - -def test_load_remote_references_batch_preserves_order(tmp_path): - src_ok = ReferenceSource(kind="remote", value="https://github.com/org/ok.git") - src_fail = ReferenceSource(kind="remote", value="https://github.com/org/fail.git") - src_ok2 = ReferenceSource(kind="remote", value="https://github.com/org/ok2.git") - - call_count = 0 - - def fake_run_git(cmd, cwd=None): - nonlocal call_count - call_count += 1 - - if "clone" in cmd: - if "fail" in cmd[4]: - return 1, "", "fatal: not found" - - clone_dir = tmp_path / "repo" - if not clone_dir.exists(): - clone_dir.mkdir(parents=True) - if not (clone_dir / "AGENTS.md").exists(): - (clone_dir / "AGENTS.md").write_text("# Rules\n") - - return 0, "", "" - - if "rev-parse" in cmd: - return 0, "abc123\n", "" - - return 1, "", "" - - with ( - patch("agentskill.lib.references._run_git", side_effect=fake_run_git), - patch("agentskill.lib.references.TemporaryDirectory") as mock_tmp, - ): - mock_tmp.return_value.__enter__ = lambda s: str(tmp_path) - mock_tmp.return_value.__exit__ = lambda s, *a: None - results = load_remote_references([src_ok, src_fail, src_ok2]) - - assert len(results) == 3 - assert results[0].ok - assert not results[1].ok - assert results[2].ok diff --git a/tests/test_runner.py b/tests/test_runner.py deleted file mode 100644 index 9b6a8f3..0000000 --- a/tests/test_runner.py +++ /dev/null @@ -1,174 +0,0 @@ -import time - -from test_support import create_sample_repo - -from agentskill.lib import runner -from agentskill.lib.logging_utils import get_logger -from agentskill.lib.runner import ( - ANALYZER_TIMEOUT_SECONDS, - COMMANDS, - POLL_INTERVAL_SECONDS, - _command_kwargs, - run_all, - run_many, -) - - -def test_runner_registry_matches_expected_commands(): - assert set(COMMANDS) == { - "scan", - "measure", - "config", - "git", - "graph", - "symbols", - "tests", - } - - -def test_run_all_returns_all_command_results(tmp_path): - repo = create_sample_repo(tmp_path) - result = run_all(str(repo)) - - assert set(result) == set(COMMANDS) - assert result["scan"]["summary"]["total_files"] >= 4 - - -def test_runner_supports_lang_and_multi_repo(tmp_path): - repo_one = create_sample_repo(tmp_path / "one") - repo_two = create_sample_repo(tmp_path / "two") - - assert _command_kwargs("scan", "python") == {"lang_filter": "python"} - assert _command_kwargs("config", "python") == {} - - result = run_many([str(repo_one), str(repo_two)], "python") - assert set(result) == {str(repo_one), str(repo_two)} - assert "python" in result[str(repo_one)]["measure"] - - -def test_runner_captures_command_exceptions(monkeypatch, caplog): - original = runner.COMMANDS["scan"]["fn"] - logger = get_logger() - original_propagate = logger.propagate - - monkeypatch.setitem( - runner.COMMANDS["scan"], - "fn", - lambda repo, **kwargs: (_ for _ in ()).throw(RuntimeError("boom")), - ) - - logger.propagate = True - - try: - with caplog.at_level("ERROR", logger="agentskill"): - result = run_all("repo") - finally: - logger.propagate = original_propagate - - assert result["scan"] == {"error": "boom", "script": "scan"} - assert "Analyzer scan failed for repo repo" in caplog.text - assert "Traceback" in caplog.text - monkeypatch.setitem(runner.COMMANDS["scan"], "fn", original) - - -def test_runner_times_out_slow_commands(monkeypatch, caplog): - monkeypatch.setattr(runner, "ANALYZER_TIMEOUT_SECONDS", 0.05) - monkeypatch.setattr(runner, "POLL_INTERVAL_SECONDS", 0.01) - original = runner.COMMANDS["scan"]["fn"] - logger = get_logger() - original_propagate = logger.propagate - - def slow_command(repo, **kwargs): - time.sleep(0.2) - return {"ok": True} - - monkeypatch.setitem(runner.COMMANDS["scan"], "fn", slow_command) - logger.propagate = True - - try: - with caplog.at_level("WARNING", logger="agentskill"): - result = run_all("repo") - finally: - logger.propagate = original_propagate - - assert result["scan"] == { - "error": (f"analyzer timed out after {runner.ANALYZER_TIMEOUT_SECONDS}s"), - "script": "scan", - } - - assert "Analyzer scan timed out after 0.05s for repo repo" in caplog.text - - monkeypatch.setitem(runner.COMMANDS["scan"], "fn", original) - - -def test_runner_handles_mixed_success_exception_and_timeout(monkeypatch, caplog): - monkeypatch.setattr(runner, "ANALYZER_TIMEOUT_SECONDS", 0.05) - monkeypatch.setattr(runner, "POLL_INTERVAL_SECONDS", 0.01) - - originals = {name: metadata["fn"] for name, metadata in runner.COMMANDS.items()} - logger = get_logger() - original_propagate = logger.propagate - - monkeypatch.setitem( - runner.COMMANDS, - "scan", - {"fn": lambda repo, **kwargs: {"ok": "scan"}, "supports_lang": True}, - ) - - monkeypatch.setitem( - runner.COMMANDS, - "measure", - { - "fn": lambda repo, **kwargs: (_ for _ in ()).throw(RuntimeError("boom")), - "supports_lang": True, - }, - ) - - def slow_command(repo, **kwargs): - time.sleep(0.2) - return {"ok": "config"} - - monkeypatch.setitem( - runner.COMMANDS, - "config", - {"fn": slow_command, "supports_lang": False}, - ) - - for name in ["git", "graph", "symbols", "tests"]: - monkeypatch.setitem( - runner.COMMANDS, - name, - { - "fn": lambda repo, name=name, **kwargs: {"ok": name}, - "supports_lang": False, - }, - ) - - logger.propagate = True - - try: - with caplog.at_level("WARNING", logger="agentskill"): - result = run_all("repo", "python") - finally: - logger.propagate = original_propagate - - assert result["scan"] == {"ok": "scan"} - assert result["measure"] == {"error": "boom", "script": "measure"} - - assert result["config"] == { - "error": (f"analyzer timed out after {runner.ANALYZER_TIMEOUT_SECONDS}s"), - "script": "config", - } - - assert set(result) == set(runner.COMMANDS) - assert caplog.text.count("Analyzer measure failed for repo repo") == 1 - assert caplog.text.count("Analyzer config timed out after 0.05s for repo repo") == 1 - assert "Traceback" in caplog.text - - for name, fn in originals.items(): - monkeypatch.setitem(runner.COMMANDS[name], "fn", fn) - - -def test_runner_module_constants_are_stable(): - assert ANALYZER_TIMEOUT_SECONDS == 60 - assert POLL_INTERVAL_SECONDS == 0.1 diff --git a/tests/test_rust_graph.py b/tests/test_rust_graph.py deleted file mode 100644 index 131f8cc..0000000 --- a/tests/test_rust_graph.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Tests for Rust module graph extraction.""" - -from test_support import create_repo - -from agentskill.commands.graph import ( - _extract_rust_mods_and_uses, - _resolve_rust_mod, - _strip_rust_comments, - build_graph, -) - - -class TestRustCommentStripping: - def test_strip_line_comments(self): - source = "mod parser; // comment" - result = _strip_rust_comments(source) - assert "//" not in result - assert "mod parser" in result - - def test_strip_block_comments(self): - source = "/* comment */ mod parser;" - result = _strip_rust_comments(source) - assert "/*" not in result - assert "mod parser" in result - - -class TestRustModExtraction: - def test_extract_mod_declarations(self): - source = "pub mod parser;\nmod config;\n" - results = _extract_rust_mods_and_uses(source) - mods = [r for r in results if r[0].startswith("mod:")] - mod_names = [r[0][4:] for r in mods] - assert "parser" in mod_names - assert "config" in mod_names - - def test_extract_use_statements(self): - source = "use crate::parser::parse;\nuse super::utils;\n" - results = _extract_rust_mods_and_uses(source) - uses = [r for r in results if r[0].startswith("use:")] - use_paths = [r[0][4:] for r in uses] - assert "crate::parser::parse" in use_paths - assert "super::utils" in use_paths - - def test_ignores_external_crates(self): - source = "use std::collections::HashMap;\nuse serde::Deserialize;\n" - results = _extract_rust_mods_and_uses(source) - uses = [r for r in results if r[0].startswith("use:")] - assert len(uses) == 0 - - def test_ignores_mods_in_comments(self): - source = "// mod ignored;\n/* mod also_ignored; */\n" - results = _extract_rust_mods_and_uses(source) - assert len(results) == 0 - - -class TestRustModResolution: - def test_resolve_sibling_file(self, tmp_path): - (tmp_path / "src").mkdir() - (tmp_path / "src" / "parser.rs").write_text("") - - files = {"src/parser.rs"} - result = _resolve_rust_mod( - "parser", tmp_path / "src" / "lib.rs", tmp_path, files - ) - assert result == "src/parser.rs" - - def test_resolve_mod_rs_file(self, tmp_path): - (tmp_path / "src").mkdir() - (tmp_path / "src" / "parser").mkdir() - (tmp_path / "src" / "parser" / "mod.rs").write_text("") - - files = {"src/parser/mod.rs"} - result = _resolve_rust_mod( - "parser", tmp_path / "src" / "lib.rs", tmp_path, files - ) - assert result == "src/parser/mod.rs" - - def test_returns_none_for_missing(self, tmp_path): - files: set[str] = set() - result = _resolve_rust_mod( - "missing", tmp_path / "src" / "lib.rs", tmp_path, files - ) - assert result is None - - -class TestRustGraphIntegration: - def test_graph_resolves_mod_declarations(self, tmp_path): - repo = create_repo( - tmp_path, - { - "Cargo.toml": '[package]\nname = "demo"\nversion = "0.1.0"\nedition = "2021"\n', - "src/lib.rs": "pub mod parser;\nmod config;\n", - "src/parser.rs": "pub fn parse() {}\n", - "src/config.rs": "fn load() {}\n", - }, - ) - - result = build_graph(str(repo), "rust") - - assert any( - e["from"] == "src/lib.rs" and e["to"] == "src/parser.rs" - for e in result["rust"]["edges"] - ) - assert any( - e["from"] == "src/lib.rs" and e["to"] == "src/config.rs" - for e in result["rust"]["edges"] - ) - - def test_graph_ignores_external_use_paths(self, tmp_path): - repo = create_repo( - tmp_path, - { - "Cargo.toml": '[package]\nname = "demo"\n', - "src/lib.rs": "use std::collections::HashMap;\n", - }, - ) - - result = build_graph(str(repo), "rust") - assert len(result["rust"]["edges"]) == 0 diff --git a/tests/test_rust_symbols.py b/tests/test_rust_symbols.py deleted file mode 100644 index b4840dd..0000000 --- a/tests/test_rust_symbols.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Tests for Rust symbol extraction.""" - -from test_support import create_repo - -from agentskill.commands.symbols import extract_symbols - - -class TestRustSymbolExtraction: - def test_extracts_public_functions(self, tmp_path): - repo = create_repo( - tmp_path, - {"src/lib.rs": "pub fn parse() {}\n"}, - ) - result = extract_symbols(str(repo), "rust") - assert result["rust"]["functions"]["total"] >= 1 - - def test_extracts_private_functions(self, tmp_path): - repo = create_repo( - tmp_path, - {"src/lib.rs": "fn helper() {}\n"}, - ) - result = extract_symbols(str(repo), "rust") - assert result["rust"]["functions"]["total"] >= 1 - - def test_extracts_structs(self, tmp_path): - repo = create_repo( - tmp_path, - {"src/lib.rs": "pub struct Parser {}\nstruct Internal {}\n"}, - ) - result = extract_symbols(str(repo), "rust") - assert result["rust"]["structs"]["total"] >= 2 - - def test_extracts_enums(self, tmp_path): - repo = create_repo( - tmp_path, - {"src/lib.rs": "pub enum Status { Active, Inactive }\n"}, - ) - result = extract_symbols(str(repo), "rust") - assert result["rust"]["enums"]["total"] >= 1 - - def test_extracts_traits(self, tmp_path): - repo = create_repo( - tmp_path, - {"src/lib.rs": "pub trait Store {}\n"}, - ) - result = extract_symbols(str(repo), "rust") - assert "traits" in result["rust"] - assert result["rust"]["traits"]["total"] >= 1 - - def test_extracts_impls(self, tmp_path): - repo = create_repo( - tmp_path, - {"src/lib.rs": "impl Parser {}\nimpl Store for Parser {}\n"}, - ) - result = extract_symbols(str(repo), "rust") - assert "impls" in result["rust"] - assert result["rust"]["impls"]["total"] >= 1 - - def test_extracts_constants(self, tmp_path): - repo = create_repo( - tmp_path, - {"src/lib.rs": 'pub const VERSION: &str = "1";\n'}, - ) - result = extract_symbols(str(repo), "rust") - assert result["rust"]["constants"]["total"] >= 1 - - def test_extracts_statics(self, tmp_path): - repo = create_repo( - tmp_path, - {"src/lib.rs": "static COUNTER: u64 = 0;\n"}, - ) - result = extract_symbols(str(repo), "rust") - assert "statics" in result["rust"] - assert result["rust"]["statics"]["total"] >= 1 diff --git a/tests/test_rust_tests.py b/tests/test_rust_tests.py deleted file mode 100644 index 84f88af..0000000 --- a/tests/test_rust_tests.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Tests for Rust test detection and mapping.""" - -from test_support import create_repo - -from agentskill.commands.tests import analyze_tests - - -class TestRustTestDetection: - def test_detects_inline_tests(self, tmp_path): - repo = create_repo( - tmp_path, - { - "Cargo.toml": '[package]\nname = "demo"\nversion = "0.1.0"\n', - "src/lib.rs": ( - "pub fn parse() {}\n\n" - "#[cfg(test)]\nmod tests {\n" - " #[test]\n fn parses_input() {}\n}\n" - ), - }, - ) - result = analyze_tests(str(repo)) - assert result["rust"]["source_files"] >= 1 - - def test_detects_integration_test_files(self, tmp_path): - repo = create_repo( - tmp_path, - { - "Cargo.toml": '[package]\nname = "demo"\nversion = "0.1.0"\n', - "src/lib.rs": "pub fn parse() {}\n", - "tests/parser_test.rs": "use demo::parse;\n#[test]\nfn test_parse() {}\n", - }, - ) - result = analyze_tests(str(repo)) - assert result["rust"]["test_files"] >= 1 - - def test_detects_rust_framework(self, tmp_path): - repo = create_repo( - tmp_path, - { - "Cargo.toml": '[package]\nname = "demo"\nversion = "0.1.0"\n', - "src/lib.rs": "pub fn parse() {}\n\n#[cfg(test)]\nmod tests {\n #[test]\n fn works() {}\n}\n", - }, - ) - result = analyze_tests(str(repo)) - assert result["rust"]["framework"] == "cargo test" - - -class TestRustTestMapping: - def test_maps_test_to_source_file(self, tmp_path): - repo = create_repo( - tmp_path, - { - "Cargo.toml": '[package]\nname = "demo"\nversion = "0.1.0"\n', - "src/parser.rs": "pub fn parse() {}\n", - "src/parser_test.rs": "#[test]\nfn test_parse() {}\n", - }, - ) - result = analyze_tests(str(repo)) - coverage = result["rust"]["coverage_shape"] - if coverage["mapped"]: - assert coverage["mapped"][0]["source"] == "src/parser.rs" - - def test_identifies_untested_source_files(self, tmp_path): - repo = create_repo( - tmp_path, - { - "Cargo.toml": '[package]\nname = "demo"\nversion = "0.1.0"\n', - "src/covered.rs": "pub fn covered() {}\n", - "src/covered_test.rs": "#[test]\nfn test_covered() {}\n", - "src/uncovered.rs": "pub fn uncovered() {}\n", - }, - ) - result = analyze_tests(str(repo)) - coverage = result["rust"]["coverage_shape"] - assert "src/uncovered.rs" in coverage["untested_source_files"] diff --git a/tests/test_scan.py b/tests/test_scan.py deleted file mode 100644 index 8bb6aa3..0000000 --- a/tests/test_scan.py +++ /dev/null @@ -1,187 +0,0 @@ -import json -import subprocess -import sys -from pathlib import Path - -from test_support import ROOT, create_repo, create_sample_repo - -from agentskill.commands import scan as scan_command -from agentskill.commands.scan import scan -from agentskill.common.walk import walk_repo as shared_walk_repo - - -def test_scan_collects_language_summary(tmp_path): - repo = create_sample_repo(tmp_path) - result = scan(str(repo)) - - assert result["summary"]["by_language"]["python"]["file_count"] >= 4 - assert "pkg/main.py" in result["read_order"] - - -def test_scan_wrapper_still_executes_directly(tmp_path): - repo = create_sample_repo(tmp_path) - - completed = subprocess.run( - [sys.executable, str(ROOT / "scripts" / "scan.py"), str(repo), "--pretty"], - capture_output=True, - text=True, - check=True, - ) - - output = json.loads(completed.stdout) - - assert output["summary"]["total_files"] >= 4 - - -def test_scan_reports_invalid_repo_paths(tmp_path): - missing = tmp_path / "missing" - file_path = tmp_path / "file.txt" - file_path.write_text("hello\n") - - assert scan(str(missing)) == { - "error": f"path does not exist: {missing}", - "script": "scan", - } - - assert scan(str(file_path)) == { - "error": f"not a directory: {file_path}", - "script": "scan", - } - - -def test_scan_excludes_skipped_directories_and_keeps_language_filters(tmp_path): - repo = create_sample_repo(tmp_path) - (repo / ".git").mkdir() - (repo / ".git" / "ignored.py").write_text("print('ignored')\n") - (repo / "node_modules").mkdir() - (repo / "node_modules" / "ignored.js").write_text("console.log('ignored')\n") - - result = scan(str(repo), "python") - - assert all(not path.startswith(".git/") for path in result["read_order"]) - assert all(not path.startswith("node_modules/") for path in result["read_order"]) - assert set(result["summary"]["by_language"]) == {"python"} - - -def test_scan_handles_walk_repo_truncation_without_error(monkeypatch, tmp_path): - repo = create_sample_repo(tmp_path) - - monkeypatch.setattr( - scan_command, - "walk_repo", - lambda path: shared_walk_repo(path, max_files=1), - ) - - result = scan(str(repo)) - - assert result["summary"]["total_files"] <= 1 - assert len(result["tree"]) <= 1 - - -def test_scan_detects_shebang_bash_scripts_without_extension(tmp_path): - repo = tmp_path / "sample_repo" - repo.mkdir() - script = repo / "deploy" - script.write_text("#!/usr/bin/env bash\necho deploy\n") - - result = scan(str(repo)) - - assert result["summary"]["by_language"]["bash"]["file_count"] == 1 - assert "deploy" in result["read_order"] - - -def test_scan_empty_repo_returns_empty_structures(tmp_path): - repo = create_repo(tmp_path) - - result = scan(str(repo)) - - assert result == { - "tree": [], - "summary": { - "total_files": 0, - "by_language": {}, - "max_depth": 0, - "avg_depth": 0.0, - }, - "read_order": [], - } - - -def test_scan_zero_match_filter_returns_no_results(tmp_path): - repo = create_repo(tmp_path, {"pkg/main.py": "def run():\n return 1\n"}) - - result = scan(str(repo), "typescript") - - assert result["tree"] == [] - assert result["summary"]["total_files"] == 0 - assert result["summary"]["by_language"] == {} - assert result["read_order"] == [] - - -def _supports_symlinks(tmp_path: Path) -> bool: - target = tmp_path / "target.txt" - link = tmp_path / "link.txt" - target.write_text("x") - - try: - link.symlink_to(target) - except (NotImplementedError, OSError): - return False - - return link.is_symlink() - - -def test_scan_skips_symlinked_files(tmp_path): - if not _supports_symlinks(tmp_path): - return - - repo = create_repo( - tmp_path, - { - "pkg/main.py": "def run():\n return 1\n", - }, - ) - - (repo / "pkg" / "main_link.py").symlink_to(repo / "pkg" / "main.py") - result = scan(str(repo)) - - paths = [entry["path"] for entry in result["tree"]] - assert paths == ["pkg/main.py"] - - -def test_scan_skips_symlinked_directories(tmp_path): - if not _supports_symlinks(tmp_path): - return - - repo = create_repo( - tmp_path, - { - "pkg/main.py": "def run():\n return 1\n", - "shared/util.py": "def util():\n return 2\n", - }, - ) - - (repo / "pkg" / "linked_shared").symlink_to( - repo / "shared", target_is_directory=True - ) - - result = scan(str(repo)) - - paths = [entry["path"] for entry in result["tree"]] - assert "pkg/linked_shared/util.py" not in paths - assert paths == ["pkg/main.py", "shared/util.py"] - - -def test_scan_skips_binary_like_files_in_valid_repo(tmp_path): - repo = create_repo( - tmp_path, - { - "pkg/main.py": "def run():\n return 1\n", - "assets/logo.png": "fakepng\n", - "archive/data.zip": "fakezip\n", - }, - ) - - result = scan(str(repo)) - - assert [entry["path"] for entry in result["tree"]] == ["pkg/main.py"] diff --git a/tests/test_scripts_layer.py b/tests/test_scripts_layer.py deleted file mode 100644 index 0d5b460..0000000 --- a/tests/test_scripts_layer.py +++ /dev/null @@ -1,23 +0,0 @@ -from test_support import ROOT - - -def test_scripts_directory_contains_only_supported_wrapper_files(): - scripts_dir = ROOT / "scripts" - - names = sorted(path.name for path in scripts_dir.iterdir()) - - assert names == [ - "analyze.py", - "config.py", - "generate.py", - "git.py", - "graph.py", - "measure.py", - "scan.py", - "symbols.py", - "tests.py", - "update.py", - ] - - for name in names: - assert (scripts_dir / name).is_file() diff --git a/tests/test_support.py b/tests/test_support.py deleted file mode 100644 index e955959..0000000 --- a/tests/test_support.py +++ /dev/null @@ -1,110 +0,0 @@ -import subprocess -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -EXAMPLES_DIR = ROOT / "examples" - -GIT_ENV = { - "GIT_AUTHOR_NAME": "Test User", - "GIT_AUTHOR_EMAIL": "test@example.com", - "GIT_COMMITTER_NAME": "Test User", - "GIT_COMMITTER_EMAIL": "test@example.com", -} - - -def write(repo: Path, rel_path: str, content: str) -> Path: - path = repo / rel_path - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content) - return path - - -def touch_tree(repo: Path, files: dict[str, str]) -> Path: - for rel_path, content in files.items(): - write(repo, rel_path, content) - - return repo - - -def create_repo( - tmp_path: Path, files: dict[str, str] | None = None, name: str = "sample_repo" -) -> Path: - repo = tmp_path / name - repo.mkdir(parents=True, exist_ok=True) - - if files: - touch_tree(repo, files) - - return repo - - -def create_sample_repo(tmp_path: Path) -> Path: - return create_repo( - tmp_path, - { - "pyproject.toml": ( - "[tool.pytest.ini_options]\n" - 'testpaths = ["tests"]\n\n' - "[tool.ruff]\n" - "line-length = 88\n" - ), - ".editorconfig": ( - "root = true\n\n[*.py]\nindent_style = space\nindent_size = 4\n" - ), - "pkg/__init__.py": "\n", - "pkg/util.py": ( - "VALUE_NAME = 1\n\n\ndef helper_value():\n return VALUE_NAME\n" - ), - "pkg/main.py": ( - "from pkg.util import helper_value\n\n\n" - "class SampleThing:\n" - " def run_task(self):\n" - " return helper_value()\n\n\n" - "def main_entry():\n" - " return SampleThing().run_task()\n" - ), - "tests/test_main.py": ( - "import pytest\n\n" - "from pkg.main import main_entry\n\n\n" - "@pytest.fixture\n" - "def sample_fixture():\n" - " return 1\n\n\n" - "def test_main_entry(sample_fixture):\n" - " assert main_entry() == sample_fixture\n" - ), - }, - ) - - -def git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess: - env = dict(GIT_ENV) - - return subprocess.run( - ["git", *args], - cwd=repo, - check=check, - capture_output=True, - text=True, - env=env, - ) - - -def init_git_repo(repo: Path, branch: str = "main") -> None: - git(repo, "init", "-b", branch) - - -def commit_all(repo: Path, message: str, body: str | None = None) -> None: - git(repo, "add", ".") - - if body is None: - git(repo, "commit", "-m", message) - return - - git(repo, "commit", "-m", message, "-m", body) - - -def make_commit( - repo: Path, rel_path: str, content: str, message: str, body: str | None = None -) -> None: - write(repo, rel_path, content) - commit_all(repo, message, body) diff --git a/tests/test_symbols.py b/tests/test_symbols.py deleted file mode 100644 index 3316b88..0000000 --- a/tests/test_symbols.py +++ /dev/null @@ -1,412 +0,0 @@ -from test_support import create_repo, create_sample_repo - -from agentskill.commands.symbols import _classify, _find_affixes, extract_symbols - - -def test_symbols_extracts_python_patterns(tmp_path): - repo = create_sample_repo(tmp_path) - result = extract_symbols(str(repo), "python") - - assert result["python"]["functions"]["total"] >= 3 - assert result["python"]["classes"]["patterns"]["PascalCase"]["count"] >= 1 - - -def test_symbols_handles_empty_and_malformed_python_files_without_errors(tmp_path): - repo = create_repo( - tmp_path, - { - "pkg/empty.py": "", - "pkg/bad.py": "def broken(:\n", - }, - ) - - result = extract_symbols(str(repo), "python") - - assert result["python"]["functions"]["total"] == 0 - assert result["python"]["classes"]["total"] == 0 - assert result["python"]["constants"]["total"] == 0 - - assert result["python"]["private_members"] == { - "single_underscore": 0, - "double_underscore": 0, - "examples": [], - } - - assert result["python"]["files"]["total"] == 2 - - -def test_symbols_classification_and_affixes(): - assert _classify("__init__") == "dunder" - assert _classify("_hidden") == "private" - assert _classify("VALUE_NAME") == "SCREAMING_SNAKE_CASE" - assert _classify("snake_case") == "snake_case" - assert _classify("PascalCase") == "PascalCase" - assert _classify("camelCase") == "camelCase" - assert _classify("misc") == "other" - - affixes = _find_affixes( - ["buildGraph", "buildTree", "buildValue", "buildNode", "buildThing"] - ) - - assert any( - entry["pattern"] == "bu_ prefix" or entry["pattern"] == "build_ prefix" - for entry in affixes - ) - - -def test_symbols_extracts_typescript_and_go(tmp_path): - repo = create_repo( - tmp_path, - { - "src/app.ts": ( - "export function buildThing() {}\n" - "const makeWidget = () => {}\n" - "export class WidgetService {}\n" - "export interface WidgetShape {}\n" - "export type WidgetType = string\n" - "export const VALUE_NAME = 1\n" - ), - "pkg/main.go": ( - "package main\n" - "type Worker struct{}\n" - "const (\n MainValue = 1\n)\n" - "var ExportedValue string\n" - "func RunThing() {}\n" - ), - }, - ) - - result = extract_symbols(str(repo)) - - assert result["typescript"]["classes"]["total"] >= 2 - - assert ( - result["typescript"]["constants"]["patterns"]["SCREAMING_SNAKE_CASE"]["count"] - >= 1 - ) - - assert result["go"]["functions"]["total"] >= 1 - assert result["go"]["constants"]["total"] >= 1 - assert result["go"]["variables"]["total"] >= 1 - - -def test_symbols_extracts_typescript_arrow_and_const_forms_precisely(tmp_path): - repo = create_repo( - tmp_path, - { - "src/app.ts": ( - "export const buildThing = () => {}\n" - "const localThing = () => {}\n" - "export const VALUE_NAME = 1\n" - "const localValue = 2\n" - ), - }, - ) - - result = extract_symbols(str(repo), "typescript") - - assert result["typescript"]["functions"]["total"] == 2 - assert result["typescript"]["constants"]["total"] == 1 - - assert result["typescript"]["constants"]["patterns"] == { - "SCREAMING_SNAKE_CASE": {"count": 1, "pct": 100.0} - } - - -def test_symbols_extracts_go_grouped_constants_and_methods_precisely(tmp_path): - repo = create_repo( - tmp_path, - { - "pkg/main.go": ( - "package main\n" - "type Worker struct{}\n" - "type Reader interface{}\n" - "type Alias string\n" - "const (\n" - " FirstValue = 1\n" - " SecondValue = 2\n" - ")\n" - "var ExportedValue string\n" - "func RunThing() {}\n" - "func (w *Worker) StartThing() {}\n" - ), - }, - ) - - result = extract_symbols(str(repo), "go") - - assert result["go"]["functions"]["total"] == 2 - assert result["go"]["methods"]["total"] == 1 - assert result["go"]["types"]["total"] == 1 - assert result["go"]["interfaces"]["total"] == 1 - assert result["go"]["type_aliases"]["total"] == 1 - assert result["go"]["constants"]["total"] == 2 - assert result["go"]["variables"]["total"] == 1 - - -def test_symbols_extracts_java_and_kotlin(tmp_path): - repo = create_repo( - tmp_path, - { - "src/main/java/com/acme/UserService.java": ( - "package com.acme;\n\n" - "public class UserService {\n" - " public UserService() {}\n" - " public void start() {}\n" - ' private String helper() { return ""; }\n' - "}\n\n" - "interface Store {}\n" - "enum Status {}\n" - "@interface Marker {}\n" - ), - "src/main/kotlin/com/acme/App.kt": ( - "package com.acme\n\n" - "class UserService\n" - "data class User(val id: String)\n" - "sealed class Result\n" - "interface Store\n" - "object AppConfig\n" - "enum class Status { OK }\n\n" - "fun start() {}\n" - "private fun helper() {}\n" - 'const val VERSION = "1"\n' - 'val name = "x"\n' - ), - }, - ) - - result = extract_symbols(str(repo)) - - assert result["java"]["classes"]["patterns"]["PascalCase"]["count"] >= 1 - assert result["java"]["methods"]["total"] >= 2 - assert result["java"]["interfaces"]["total"] >= 1 - assert result["java"]["enums"]["total"] >= 1 - assert result["java"]["annotations"]["total"] >= 1 - assert result["java"]["constructors"]["total"] >= 1 - - assert result["kotlin"]["classes"]["total"] >= 3 - assert result["kotlin"]["interfaces"]["total"] >= 1 - assert result["kotlin"]["objects"]["total"] >= 1 - assert result["kotlin"]["enums"]["total"] >= 1 - assert result["kotlin"]["functions"]["total"] >= 2 - assert result["kotlin"]["constants"]["total"] >= 1 - assert result["kotlin"]["properties"]["total"] >= 1 - - -def test_symbols_extracts_csharp_c_and_cpp(tmp_path): - repo = create_repo( - tmp_path, - { - "src/UserService.cs": ( - "namespace Acme.Service;\n\n" - "public class UserService {\n" - " public void Start() {}\n" - ' private string Normalize() { return ""; }\n' - "}\n\n" - "public interface IUserStore {}\n" - "internal struct UserId {}\n" - "public enum Status {}\n" - "public record User(string Id);\n" - ), - "src/main.c": ( - "#define MAX_SIZE 100\n\n" - "typedef struct User User;\n" - "struct User {};\n\n" - "enum Status { OK };\n\n" - "int add(int a, int b) {\n return a + b;\n}\n" - ), - "src/app.cpp": ( - "namespace acme {}\n\n" - "template \n" - "class Box {};\n" - "class UserService {};\n" - "struct User {};\n" - "enum class Status {};\n\n" - "int add(int a, int b) {\n return a + b;\n}\n" - ), - }, - ) - - result = extract_symbols(str(repo)) - - assert result["csharp"]["classes"]["total"] >= 1 - assert result["csharp"]["methods"]["total"] >= 2 - assert result["csharp"]["interfaces"]["total"] >= 1 - assert result["csharp"]["structs"]["total"] >= 1 - assert result["csharp"]["enums"]["total"] >= 1 - assert result["csharp"]["records"]["total"] >= 1 - - assert result["c"]["functions"]["total"] >= 1 - assert result["c"]["structs"]["total"] >= 1 - assert result["c"]["enums"]["total"] >= 1 - assert result["c"]["typedefs"]["total"] >= 1 - assert result["c"]["macros"]["total"] >= 1 - - assert result["cpp"]["functions"]["total"] >= 1 - assert result["cpp"]["namespaces"]["total"] >= 1 - assert result["cpp"]["classes"]["total"] >= 2 - assert result["cpp"]["structs"]["total"] >= 1 - assert result["cpp"]["enums"]["total"] >= 1 - assert result["cpp"]["templates"]["total"] >= 1 - - -def test_symbols_extracts_ruby_php_and_bash(tmp_path): - repo = create_repo( - tmp_path, - { - "lib/user_service.rb": ( - "module MyApp\nend\n\n" - "class UserService\n" - " def call\n end\n\n" - " def self.build\n end\n" - "end\n" - ), - "src/Service/UserService.php": ( - "= 1 - assert result["ruby"]["classes"]["total"] >= 1 - assert result["ruby"]["methods"]["total"] >= 1 - assert result["ruby"]["class_methods"]["total"] >= 1 - - assert result["php"]["classes"]["total"] >= 1 - assert result["php"]["methods"]["total"] >= 2 - assert result["php"]["interfaces"]["total"] >= 1 - assert result["php"]["traits"]["total"] >= 1 - assert result["php"]["enums"]["total"] >= 1 - assert result["php"]["functions"]["total"] >= 1 - - assert result["bash"]["functions"]["total"] >= 2 - - -def test_symbols_ignores_commented_bash_and_php_declarations(tmp_path): - repo = create_repo( - tmp_path, - { - "scripts/deploy": ( - "#!/usr/bin/env bash\n# fake() {\n# }\nreal() {\n echo deploy\n}\n" - ), - "src/Service/UserService.php": ( - "= 1 - assert result["swift"]["classes"]["total"] >= 1 - assert result["swift"]["enums"]["total"] >= 1 - assert result["swift"]["protocols"]["total"] >= 1 - assert result["swift"]["functions"]["total"] >= 2 - assert result["swift"]["extensions"]["total"] >= 1 - - assert result["objectivec"]["interfaces"]["total"] >= 1 - assert result["objectivec"]["implementations"]["total"] >= 1 - assert result["objectivec"]["methods"]["total"] >= 1 - assert result["objectivec"]["class_methods"]["total"] >= 1 - assert result["objectivec"]["protocols"]["total"] >= 1 - - -def test_symbols_extracts_swift_extensions_without_extra_types(tmp_path): - repo = create_repo( - tmp_path, - { - "Sources/MyApp/Extensions.swift": ( - "extension UserService {}\nextension AppStore {}\n" - ), - }, - ) - - result = extract_symbols(str(repo), "swift") - - assert result["swift"]["extensions"]["total"] == 2 - assert result["swift"]["functions"]["total"] == 0 - assert "classes" not in result["swift"] - - -def test_symbols_distinguishes_objectivec_instance_and_class_methods(tmp_path): - repo = create_repo( - tmp_path, - { - "Sources/UserService.h": "@interface UserService : NSObject\n@end\n", - "Sources/UserService.m": ( - "@implementation UserService\n" - "- (void)start {}\n" - "+ (instancetype)shared {}\n" - "@end\n" - ), - }, - ) - - result = extract_symbols(str(repo), "objectivec") - - assert result["objectivec"]["methods"]["total"] == 1 - assert result["objectivec"]["class_methods"]["total"] == 1 - - -def test_symbols_reports_invalid_repo_paths(tmp_path): - missing = tmp_path / "missing" - - assert extract_symbols(str(missing)) == { - "error": f"path does not exist: {missing}", - "script": "symbols", - } diff --git a/tests/test_tests.py b/tests/test_tests.py deleted file mode 100644 index 87c9c8e..0000000 --- a/tests/test_tests.py +++ /dev/null @@ -1,313 +0,0 @@ -from test_support import create_repo, create_sample_repo, write - -from agentskill.commands.tests import ( - _detect_python_framework, - _detect_ts_framework, - _extract_run_command, - _find_conftest_files, - _map_jvm_tests, - _map_python_tests, - _map_stem_tests, - analyze_tests, -) - - -def test_tests_detects_pytest_and_mappings(tmp_path): - repo = create_sample_repo(tmp_path) - result = analyze_tests(str(repo)) - - assert result["python"]["framework"] == "pytest" - assert result["python"]["fixtures"]["uses_conftest"] is False - assert result["python"]["coverage_shape"]["mapped"] - - -def test_tests_detect_frameworks_run_command_and_conftest(tmp_path): - repo = create_repo( - tmp_path, - { - "pytest.ini": "[pytest]\n", - "Makefile": "test:\n\tpython -m pytest -q\n", - "pkg/mod.py": "def run():\n return 1\n", - "tests/test_mod.py": "def test_run():\n assert True\n", - "tests/conftest.py": ( - "import pytest\n\n@pytest.fixture\ndef repo_fixture():\n return 1\n" - ), - }, - ) - - result = analyze_tests(str(repo)) - - assert _detect_python_framework(repo, []) == "pytest" - assert _extract_run_command(repo, "pytest") == "python -m pytest -q" - assert _find_conftest_files(repo) == ["tests/conftest.py"] - assert result["python"]["fixtures"]["fixture_names"] == ["repo_fixture"] - - -def test_tests_detect_unittest_ts_and_mapping_gaps(tmp_path): - repo = create_repo(tmp_path) - write(repo, "pkg/core.py", "def work():\n return 1\n") - write(repo, "pkg/extra.py", "def extra():\n return 2\n") - - write( - repo, - "tests/core_test.py", - "import unittest\n\nclass CoreTest(unittest.TestCase):\n pass\n", - ) - - write( - repo, - "package.json", - '{"scripts":{"test":"vitest run"},"devDependencies":{"vitest":"1.0.0"}}\n', - ) - - write(repo, "src/app.ts", "export function run() { return 1 }\n") - write(repo, "src/app.spec.ts", "describe('app', () => { it('works', () => {}) })\n") - - mapping = _map_python_tests( - [repo / "pkg" / "core.py", repo / "pkg" / "extra.py"], - [repo / "tests" / "core_test.py"], - repo, - ) - - result = analyze_tests(str(repo)) - framework, command = _detect_ts_framework(repo) - - assert result["python"]["framework"] == "unittest" - - assert mapping["mapped"] == [ - {"source": "pkg/core.py", "test": "tests/core_test.py"} - ] - - assert mapping["test_files_without_source_match"] == [] - assert sorted(mapping["untested_source_files"]) == ["pkg/extra.py"] - assert framework == "vitest" - assert command == "vitest run" - assert result["typescript"]["naming"]["file_pattern"] == ".spec.ts" - - -def test_tests_detect_java_and_kotlin_mappings(tmp_path): - repo = create_repo( - tmp_path, - { - "pom.xml": "\n", - "build.gradle.kts": "plugins {}\n", - "src/main/java/com/acme/UserService.java": ( - "package com.acme;\npublic class UserService {}\n" - ), - "src/test/java/com/acme/UserServiceTest.java": ( - "import org.junit.jupiter.api.Test;\n\n" - "class UserServiceTest {\n" - " @Test\n" - " void starts() {}\n" - "}\n" - ), - "src/main/kotlin/com/acme/UserService.kt": ( - "package com.acme\nclass UserService\n" - ), - "src/test/kotlin/com/acme/UserServiceTest.kt": ( - "import kotlin.test.Test\n\n" - "class UserServiceTest {\n" - " @Test\n" - " fun works() {}\n" - "}\n" - ), - }, - ) - - result = analyze_tests(str(repo)) - assert result["java"]["framework"] == "junit" - - assert result["java"]["coverage_shape"]["mapped"] == [ - { - "source": "src/main/java/com/acme/UserService.java", - "test": "src/test/java/com/acme/UserServiceTest.java", - } - ] - - assert result["kotlin"]["framework"] == "kotlin-test" - - assert result["kotlin"]["coverage_shape"]["mapped"] == [ - { - "source": "src/main/kotlin/com/acme/UserService.kt", - "test": "src/test/kotlin/com/acme/UserServiceTest.kt", - } - ] - - -def test_map_jvm_tests_reports_untested_sources(tmp_path): - repo = create_repo( - tmp_path, - { - "src/main/java/com/acme/UserService.java": "public class UserService {}\n", - "src/main/java/com/acme/Helper.java": "class Helper {}\n", - "src/test/java/com/acme/UserServiceTests.java": "class UserServiceTests {}\n", - }, - ) - - mapping = _map_jvm_tests( - [ - repo / "src/main/java/com/acme/UserService.java", - repo / "src/main/java/com/acme/Helper.java", - ], - [repo / "src/test/java/com/acme/UserServiceTests.java"], - repo, - ) - - assert mapping["mapped"] == [ - { - "source": "src/main/java/com/acme/UserService.java", - "test": "src/test/java/com/acme/UserServiceTests.java", - } - ] - assert mapping["untested_source_files"] == ["src/main/java/com/acme/Helper.java"] - - -def test_tests_detect_csharp_and_c_family_mappings(tmp_path): - repo = create_repo( - tmp_path, - { - "src/UserService.cs": "public class UserService {}\n", - "tests/UserServiceTests.cs": ( - "using Xunit;\n\n" - "public class UserServiceTests {\n" - " [Fact]\n" - " public void Starts() {}\n" - "}\n" - ), - "src/foo.c": "int add(int a, int b) { return a + b; }\n", - "tests/foo_test.c": '#include "unity.h"\n', - "src/bar.cpp": "int add(int a, int b) { return a + b; }\n", - "tests/bar_test.cpp": "#include \nTEST(BarTest, Works) {}\n", - }, - ) - - result = analyze_tests(str(repo)) - - assert result["csharp"]["framework"] == "xunit" - assert result["csharp"]["coverage_shape"]["mapped"] == [ - {"source": "src/UserService.cs", "test": "tests/UserServiceTests.cs"} - ] - - assert result["c"]["framework"] == "unity" - assert result["c"]["coverage_shape"]["mapped"] == [ - {"source": "src/foo.c", "test": "tests/foo_test.c"} - ] - - assert result["cpp"]["framework"] == "gtest" - assert result["cpp"]["coverage_shape"]["mapped"] == [ - {"source": "src/bar.cpp", "test": "tests/bar_test.cpp"} - ] - - -def test_map_stem_tests_reports_untested_sources(tmp_path): - repo = create_repo( - tmp_path, - { - "src/foo.c": "int add(int a, int b) { return a + b; }\n", - "src/helper.c": "int helper(void) { return 1; }\n", - "tests/foo_test.c": "void test_add(void) {}\n", - }, - ) - - mapping = _map_stem_tests( - [repo / "src/foo.c", repo / "src/helper.c"], - [repo / "tests/foo_test.c"], - repo, - ) - - assert mapping["mapped"] == [{"source": "src/foo.c", "test": "tests/foo_test.c"}] - assert mapping["untested_source_files"] == ["src/helper.c"] - - -def test_tests_detect_ruby_php_and_bash_mappings(tmp_path): - repo = create_repo( - tmp_path, - { - "lib/user_service.rb": "class UserService\nend\n", - "spec/user_service_spec.rb": "RSpec.describe UserService do\nend\n", - "test/user_service_test.rb": ( - 'require "minitest/autorun"\nclass UserServiceTest < Minitest::Test\nend\n' - ), - "src/Service/UserService.php": "\n\n" - "@interface UserServiceTests : XCTestCase\n@end\n\n" - "@implementation UserServiceTests\n" - "- (void)testStart {}\n" - "@end\n" - ), - }, - ) - - result = analyze_tests(str(repo)) - - assert result["swift"]["framework"] == "xctest" - assert result["swift"]["coverage_shape"]["mapped"] == [ - { - "source": "Sources/MyApp/UserService.swift", - "test": "Tests/MyAppTests/UserServiceTests.swift", - } - ] - - assert result["objectivec"]["framework"] == "xctest" - assert result["objectivec"]["coverage_shape"]["mapped"] == [ - {"source": "Sources/UserService.m", "test": "Tests/UserServiceTests.m"} - ] - - -def test_tests_reports_invalid_repo_paths(tmp_path): - missing = tmp_path / "missing" - - assert analyze_tests(str(missing)) == { - "error": f"path does not exist: {missing}", - "script": "tests", - } diff --git a/tests/test_update_cli.py b/tests/test_update_cli.py deleted file mode 100644 index eb3aec6..0000000 --- a/tests/test_update_cli.py +++ /dev/null @@ -1,183 +0,0 @@ -from pathlib import Path - -from test_support import create_sample_repo, write - -from agentskill.main import main - - -def test_update_creates_agents_file_when_missing(tmp_path): - repo = create_sample_repo(tmp_path) - - exit_code = main(["update", str(repo)]) - assert exit_code == 0 - - agents_text = (repo / "AGENTS.md").read_text() - assert agents_text.startswith("# AGENTS.md\n\n## 1. Overview\n") - assert "## 5. Commands and Workflows\n" in agents_text - assert "## 12. Testing\n" in agents_text - - -def test_update_preserves_untouched_sections_with_include_filter(tmp_path): - repo = create_sample_repo(tmp_path) - write( - repo, - "AGENTS.md", - ( - "# AGENTS\n\n" - "## 1. Overview\n" - "Old overview.\n" - "## 12. Testing\n" - "Manual testing notes.\n" - ), - ) - - exit_code = main(["update", str(repo), "--section", "overview"]) - - assert exit_code == 0 - - agents_text = (repo / "AGENTS.md").read_text() - assert "Old overview.\n" not in agents_text - assert "Manual testing notes.\n" in agents_text - - -def test_update_excludes_selected_section(tmp_path): - repo = create_sample_repo(tmp_path) - write( - repo, - "AGENTS.md", - ("# AGENTS\n\n## 1. Overview\nOld overview.\n## 12. Testing\nOld testing.\n"), - ) - - exit_code = main(["update", str(repo), "--exclude-section", "overview"]) - assert exit_code == 0 - - agents_text = (repo / "AGENTS.md").read_text() - assert "Old overview.\n" in agents_text - assert "Old testing.\n" not in agents_text - - -def test_update_force_rebuild_drops_custom_sections(tmp_path): - repo = create_sample_repo(tmp_path) - write( - repo, - "AGENTS.md", - ( - "# AGENTS\n\n" - "## Team Notes\n" - "Keep this manually.\n" - "## 12. Testing\n" - "Old testing.\n" - ), - ) - - exit_code = main(["update", str(repo), "--force"]) - assert exit_code == 0 - - agents_text = (repo / "AGENTS.md").read_text() - assert "Team Notes" not in agents_text - assert agents_text.startswith("# AGENTS.md\n\n## 1. Overview\n") - - -def test_update_rejects_conflicting_include_and_exclude(tmp_path, capsys): - repo = create_sample_repo(tmp_path) - - exit_code = main( - [ - "update", - str(repo), - "--section", - "overview", - "--exclude-section", - "overview", - ] - ) - - assert exit_code == 1 - assert "both included and excluded" in capsys.readouterr().err - - -def test_update_supports_custom_output_path(tmp_path, monkeypatch): - repo = create_sample_repo(tmp_path) - monkeypatch.chdir(tmp_path) - out_path = Path("generated/AGENTS-new.md") - - exit_code = main(["update", str(repo), "--out", str(out_path)]) - - assert exit_code == 0 - assert out_path.exists() - assert not (repo / "AGENTS.md").exists() - - -def test_update_preserves_manual_preamble_and_custom_sections_in_normal_mode(tmp_path): - repo = create_sample_repo(tmp_path) - write( - repo, - "AGENTS.md", - ( - "Manual preamble.\n\n" - "## Team Notes\n" - "Keep this manual section.\n\n" - "## 12. Testing\n" - "Old testing.\n" - ), - ) - - exit_code = main(["update", str(repo), "--section", "testing"]) - - assert exit_code == 0 - agents_text = (repo / "AGENTS.md").read_text() - assert agents_text.startswith("Manual preamble.\n\n## Team Notes\n") - assert "Keep this manual section.\n" in agents_text - assert "Old testing.\n" not in agents_text - - -def test_update_adds_missing_targeted_section_without_rewriting_other_content(tmp_path): - repo = create_sample_repo(tmp_path) - write( - repo, - "AGENTS.md", - ( - "# AGENTS\n\n" - "## 1. Overview\n" - "Manual overview.\n\n" - "## Team Notes\n" - "Keep these notes.\n" - ), - ) - - exit_code = main(["update", str(repo), "--section", "testing"]) - - assert exit_code == 0 - agents_text = (repo / "AGENTS.md").read_text() - assert "Manual overview.\n" in agents_text - assert "Keep these notes.\n" in agents_text - assert "## 12. Testing\n" in agents_text - - -def test_update_out_uses_existing_repo_agents_as_merge_input(tmp_path, monkeypatch): - repo = create_sample_repo(tmp_path) - write( - repo, - "AGENTS.md", - ( - "Manual preamble.\n\n" - "## Team Notes\n" - "Keep this manual section.\n\n" - "## 12. Testing\n" - "Old testing.\n" - ), - ) - - monkeypatch.chdir(tmp_path) - out_path = Path("generated/AGENTS-new.md") - - exit_code = main( - ["update", str(repo), "--section", "testing", "--out", str(out_path)] - ) - - assert exit_code == 0 - generated = out_path.read_text() - assert generated.startswith("Manual preamble.\n\n## Team Notes\n") - assert "Keep this manual section.\n" in generated - assert "Old testing.\n" not in generated - assert "Old testing.\n" in (repo / "AGENTS.md").read_text() diff --git a/tests/test_update_e2e.py b/tests/test_update_e2e.py deleted file mode 100644 index fe0725d..0000000 --- a/tests/test_update_e2e.py +++ /dev/null @@ -1,184 +0,0 @@ -from test_support import create_sample_repo, write - -from agentskill.main import main - - -def test_update_succeeds_without_feedback_file(tmp_path): - repo = create_sample_repo(tmp_path) - exit_code = main(["update", str(repo)]) - - assert exit_code == 0 - assert (repo / "AGENTS.md").exists() - - -def test_feedback_biases_targeted_regeneration(tmp_path): - repo = create_sample_repo(tmp_path) - write( - repo, - ".agentskill-feedback.json", - ( - "{\n" - ' "sections": {\n' - ' "overview": {\n' - ' "prepend_notes": ["Mention that deployments go through GitHub Actions."],\n' - ' "pinned_facts": ["Use pytest as the canonical test runner."]\n' - " }\n" - " }\n" - "}\n" - ), - ) - - exit_code = main(["update", str(repo), "--section", "overview"]) - assert exit_code == 0 - - agents_text = (repo / "AGENTS.md").read_text() - assert "Mention that deployments go through GitHub Actions." in agents_text - assert "Use pytest as the canonical test runner." in agents_text - - -def test_feedback_preserve_sections_prevents_normal_regeneration(tmp_path): - repo = create_sample_repo(tmp_path) - write( - repo, - ".agentskill-feedback.json", - '{\n "preserve_sections": ["testing"]\n}\n', - ) - - write( - repo, - "AGENTS.md", - ( - "# AGENTS\n\n" - "## 1. Overview\n" - "Old overview.\n" - "## 12. Testing\n" - "Keep this testing guidance exactly.\n" - ), - ) - - exit_code = main(["update", str(repo)]) - assert exit_code == 0 - - agents_text = (repo / "AGENTS.md").read_text() - assert "Keep this testing guidance exactly.\n" in agents_text - assert "Old overview.\n" not in agents_text - - -def test_feedback_preserve_sections_are_ignored_in_force_mode(tmp_path): - repo = create_sample_repo(tmp_path) - write( - repo, - ".agentskill-feedback.json", - '{\n "preserve_sections": ["testing"]\n}\n', - ) - - write( - repo, - "AGENTS.md", - ("# AGENTS\n\n## 12. Testing\nKeep this testing guidance exactly.\n"), - ) - - exit_code = main(["update", str(repo), "--force"]) - assert exit_code == 0 - - agents_text = (repo / "AGENTS.md").read_text() - assert "Keep this testing guidance exactly.\n" not in agents_text - assert "## 12. Testing\n" in agents_text - - -def test_malformed_feedback_fails_clearly(tmp_path, capsys): - repo = create_sample_repo(tmp_path) - write( - repo, - ".agentskill-feedback.json", - '{\n "sections": {\n "overview": {"unknown": ["bad"]}\n }\n}\n', - ) - - exit_code = main(["update", str(repo)]) - - assert exit_code == 1 - assert "unsupported feedback keys for section overview: unknown" in ( - capsys.readouterr().err - ) - - -def test_update_enriches_error_handling_with_static_source_snippets(tmp_path): - repo = tmp_path / "repo" - repo.mkdir() - - write( - repo, - "pkg/validate.py", - ( - "from pathlib import Path\n\n\n" - "def validate_repo(path: str) -> Path:\n" - " repo = Path(path).resolve()\n\n" - " if not repo.exists():\n" - ' raise ValueError(f"path does not exist: {path}")\n\n' - " return repo\n" - ), - ) - - write( - repo, - "pkg/scan.py", - ( - "from pkg.validate import validate_repo\n\n\n" - "def scan(repo_path: str) -> dict:\n" - " try:\n" - " repo = validate_repo(repo_path)\n" - " except ValueError as exc:\n" - ' return {"error": str(exc), "script": "scan"}\n\n' - ' return {"repo": str(repo)}\n' - ), - ) - - write( - repo, - "pkg/output.py", - ( - "import logging\n\n" - "logger = logging.getLogger(__name__)\n\n\n" - "def run_and_output(command_fn, repo: str, script_name: str) -> int:\n" - " try:\n" - " result = command_fn(repo)\n" - " except Exception as exc:\n" - ' logger.exception("Command %s failed for repo %s", script_name, repo)\n' - ' result = {"error": str(exc), "script": script_name}\n\n' - ' return 1 if "error" in result else 0\n' - ), - ) - - write( - repo, - "pkg/fs.py", - ( - "from pathlib import Path\n\n\n" - "def read_text(path: Path) -> str:\n" - " try:\n" - ' return path.read_text(encoding="utf-8")\n' - " except Exception:\n" - ' return ""\n' - ), - ) - - exit_code = main( - [ - "update", - str(repo), - "--section", - "error handling", - "--profile", - "comprehensive", - ] - ) - - assert exit_code == 0 - - agents_text = (repo / "AGENTS.md").read_text() - assert "Low-level validators raise `ValueError`" in agents_text - assert 'raise ValueError(f"path does not exist: {path}")' in agents_text - assert '`{"error": ..., "script": ...}` payloads' in agents_text - assert 'return {"error": str(exc), "script": "scan"}' in agents_text - assert "logger.exception" in agents_text - assert 'return ""' in agents_text diff --git a/tests/test_update_feedback.py b/tests/test_update_feedback.py deleted file mode 100644 index 1f7462f..0000000 --- a/tests/test_update_feedback.py +++ /dev/null @@ -1,110 +0,0 @@ -from test_support import create_sample_repo, write - -from agentskill.lib.update_feedback import ( - FEEDBACK_FILENAME, - SectionFeedback, - UpdateFeedback, - load_feedback, - validate_feedback, -) - - -def test_load_feedback_returns_empty_when_file_is_missing(tmp_path): - repo = create_sample_repo(tmp_path) - assert load_feedback(repo) == UpdateFeedback() - - -def test_validate_feedback_normalizes_sections_and_preserve_names(): - result = validate_feedback( - { - "sections": { - " Overview ": { - "prepend_notes": ["Mention deployments."], - "pinned_facts": ["Use pytest as the canonical test runner."], - } - }, - "preserve_sections": [" Red Lines ", "red lines"], - } - ) - - assert result == UpdateFeedback( - sections={ - "overview": SectionFeedback( - prepend_notes=["Mention deployments."], - pinned_facts=["Use pytest as the canonical test runner."], - ) - }, - preserve_sections=["red lines"], - ) - - -def test_load_feedback_reads_repo_local_sidecar_file(tmp_path): - repo = create_sample_repo(tmp_path) - write( - repo, - FEEDBACK_FILENAME, - ( - "{\n" - ' "sections": {\n' - ' "testing": {\n' - ' "pinned_facts": ["Use pytest as the canonical test runner."]\n' - " }\n" - " }\n" - "}\n" - ), - ) - - feedback = load_feedback(repo) - assert feedback.sections["testing"].pinned_facts == [ - "Use pytest as the canonical test runner." - ] - - -def test_validate_feedback_rejects_non_object_root(): - try: - validate_feedback([]) - raise AssertionError("should have raised ValueError") - except ValueError as exc: - assert str(exc) == "feedback must be an object" - - -def test_validate_feedback_rejects_unknown_section_keys(): - try: - validate_feedback( - { - "sections": { - "overview": { - "unknown": ["nope"], - } - } - } - ) - raise AssertionError("should have raised ValueError") - except ValueError as exc: - assert str(exc) == "unsupported feedback keys for section overview: unknown" - - -def test_validate_feedback_rejects_invalid_preserve_sections_shape(): - try: - validate_feedback({"preserve_sections": "testing"}) - raise AssertionError("should have raised ValueError") - except ValueError as exc: - assert str(exc) == "feedback.preserve_sections must be a list of strings" - - -def test_validate_feedback_rejects_non_string_list_items(): - try: - validate_feedback( - { - "sections": { - "testing": { - "pinned_facts": ["pytest", 1], - } - } - } - ) - raise AssertionError("should have raised ValueError") - except ValueError as exc: - assert str(exc) == ( - "feedback.sections.testing.pinned_facts must be a list of strings" - ) diff --git a/tests/test_update_merge.py b/tests/test_update_merge.py deleted file mode 100644 index 1ecfafc..0000000 --- a/tests/test_update_merge.py +++ /dev/null @@ -1,235 +0,0 @@ -from agentskill.lib.agents_document import build_section -from agentskill.lib.update_merge import MergeResult, merge_agents_document - - -def test_merge_replaces_one_existing_section(): - existing = ( - "# Overview\n" - "Original overview.\n" - "## Testing\n" - "Keep these notes.\n" - "## Git\n" - "Linear history.\n" - ) - - result = merge_agents_document( - existing, - { - "overview": build_section( - "Overview", "Updated overview.\n", heading_level=1 - ), - }, - ) - - assert result == MergeResult( - text=( - "# Overview\n\n" - "Updated overview.\n\n" - "## Testing\n\n" - "Keep these notes.\n\n" - "## Git\n\n" - "Linear history.\n\n" - ), - updated_sections=["overview"], - preserved_sections=["testing", "git"], - added_sections=[], - removed_sections=[], - forced=False, - ) - - -def test_merge_replaces_multiple_sections_and_preserves_order(): - existing = ( - "# Overview\n" - "Old overview.\n" - "## Commands and Workflows\n" - "Old commands.\n" - "## Testing\n" - "Old testing.\n" - ) - - result = merge_agents_document( - existing, - { - "overview": build_section("Overview", "New overview.\n", heading_level=1), - "commands and workflows": build_section( - "Commands and Workflows", - "New commands.\n", - ), - }, - ) - - assert result.text == ( - "# Overview\n\n" - "New overview.\n\n" - "## Commands and Workflows\n\n" - "New commands.\n\n" - "## Testing\n\n" - "Old testing.\n\n" - ) - - assert result.updated_sections == ["overview", "commands and workflows"] - assert result.preserved_sections == ["testing"] - assert result.added_sections == [] - assert result.removed_sections == [] - - -def test_merge_preserves_untouched_manual_edits_and_custom_sections(): - existing = ( - "Manual preamble.\n\n" - "# Overview\n" - "Generated summary.\n" - "## Team Notes\n" - "Manual notes stay here.\n" - "## Testing\n" - "Locally edited testing text.\n" - ) - - result = merge_agents_document( - existing, - { - "overview": build_section( - "Overview", "Refreshed summary.\n", heading_level=1 - ), - }, - ) - - assert result.text == ( - "Manual preamble.\n\n" - "# Overview\n\n" - "Refreshed summary.\n\n" - "## Team Notes\n\n" - "Manual notes stay here.\n\n" - "## Testing\n\n" - "Locally edited testing text.\n\n" - ) - - assert result.preserved_sections == ["team notes", "testing"] - - -def test_merge_adds_missing_section_at_end(): - existing = "# Overview\nSummary.\n" - - result = merge_agents_document( - existing, - { - "testing": build_section("Testing", "Added test guidance.\n"), - }, - ) - - assert result.text == ( - "# Overview\n\nSummary.\n\n## Testing\n\nAdded test guidance.\n\n" - ) - - assert result.updated_sections == [] - assert result.preserved_sections == ["overview"] - assert result.added_sections == ["testing"] - - -def test_merge_include_only_filters_targets(): - existing = "# Overview\nOld overview.\n## Testing\nOld testing.\n" - - result = merge_agents_document( - existing, - { - "overview": build_section("Overview", "New overview.\n", heading_level=1), - "testing": build_section("Testing", "New testing.\n"), - }, - include_sections=[" testing "], - ) - - assert result.text == ( - "# Overview\n\nOld overview.\n\n## Testing\n\nNew testing.\n\n" - ) - - assert result.updated_sections == ["testing"] - assert result.preserved_sections == ["overview"] - - -def test_merge_exclude_filters_targets(): - existing = "# Overview\nOld overview.\n## Testing\nOld testing.\n" - - result = merge_agents_document( - existing, - { - "overview": build_section("Overview", "New overview.\n", heading_level=1), - "testing": build_section("Testing", "New testing.\n"), - }, - exclude_sections=["overview"], - ) - - assert result.text == ( - "# Overview\n\nOld overview.\n\n## Testing\n\nNew testing.\n\n" - ) - - assert result.updated_sections == ["testing"] - assert result.preserved_sections == ["overview"] - - -def test_merge_rejects_include_exclude_overlap(): - try: - merge_agents_document( - "# Overview\nOld overview.\n", - { - "overview": build_section( - "Overview", - "New overview.\n", - heading_level=1, - ), - }, - include_sections=["Overview"], - exclude_sections=[" overview "], - ) - - raise AssertionError("should have raised ValueError") - except ValueError as exc: - assert str(exc) == ( - "section names cannot be both included and excluded: overview" - ) - - -def test_merge_rejects_duplicate_normalized_regenerated_names(): - try: - merge_agents_document( - None, - { - "Overview": build_section("Overview", "One.\n", heading_level=1), - " overview ": build_section("Overview", "Two.\n", heading_level=1), - }, - ) - - raise AssertionError("should have raised ValueError") - except ValueError as exc: - assert str(exc) == ( - "duplicate regenerated section after normalization: overview " - ) - - -def test_force_mode_rebuilds_clean_slate(): - existing = ( - "Manual preamble.\n\n" - "# Overview\n" - "Old overview.\n" - "## Team Notes\n" - "Custom notes.\n" - "## Testing\n" - "Old testing.\n" - ) - - result = merge_agents_document( - existing, - { - "testing": build_section("Testing", "Fresh testing.\n"), - "overview": build_section("Overview", "Fresh overview.\n", heading_level=1), - }, - force=True, - ) - - assert result == MergeResult( - text="# Overview\n\nFresh overview.\n\n## Testing\n\nFresh testing.\n\n", - updated_sections=["overview", "testing"], - preserved_sections=[], - added_sections=[], - removed_sections=["team notes"], - forced=True, - )