From 55699711e8d3ee6b96c677bec4339858d9ee065b Mon Sep 17 00:00:00 2001 From: Utmost Creator Date: Mon, 13 Jul 2026 17:50:47 +0100 Subject: [PATCH 01/62] Prepare AgentKit v0.1.0: pure-Bash toolkit + multi-channel distribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish the releasable tree for the `agent-kit` CLI (dispatcher bin/agent-kit → libexec/*, shared lib/*): pure-Bash sh-introspect powering every --help/--introspect, self-documenting commands with runnable examples, and four install channels (install.sh, web-install.sh curl|bash, Homebrew Formula, npm @utmostcreator/agent-kit) hardened for macOS Bash 3.2, path spaces, and clobber/traversal safety. Adds --version, docs/EXAMPLES.md (akit alias), governance files, CI/release workflows, and the test suite. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AtARY1YQfKg1fcMMpbTueM --- .editorconfig | 15 + .gitattributes | 12 + .github/CODEOWNERS | 8 + .github/ISSUE_TEMPLATE/bug_report.yml | 50 + .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.yml | 30 + .github/copilot-instructions.md | 14 + .github/instructions/shell.instructions.md | 5 + .github/pull_request_template.md | 21 + .github/workflows/ci.yml | 67 + .github/workflows/release.yml | 54 + .gitignore | 50 + AGENTS.md | 60 + CHANGELOG.md | 15 + CLAUDE.md | 3 + CODE_OF_CONDUCT.md | 7 + CONTRIBUTING.md | 30 + Formula/agent-kit.rb | 40 + GITHUB_METADATA.md | 73 ++ INSTALL.md | 137 ++ LICENSE | 201 +++ NOTICE | 10 + README.md | 178 +++ RELEASE_CHECKLIST.md | 44 + SECURITY.md | 27 + SUPPORT.md | 47 + VERSION | 1 + bin/agent-kit | 109 ++ docs/AI_USAGE.md | 29 + docs/COMMANDS.md | 31 + docs/EXAMPLES.md | 355 ++++++ docs/SECURITY_MODEL.md | 33 + hooks/agent/session-checkpoint | 7 + hooks/agent/watch-loop | 7 + install.sh | 136 ++ lib/ai-diff-context/commands.sh | 193 +++ lib/ai-diff-context/helpers.sh | 395 ++++++ lib/ai-diff-context/main.sh | 32 + lib/ai-edit/helpers.sh | 244 ++++ lib/ai-edit/main.sh | 181 +++ lib/ai-edit/parse.sh | 115 ++ lib/ai-edit/plan-apply.sh | 210 ++++ lib/ai-search/backend-ast.sh | 109 ++ lib/ai-search/backend-curated.sh | 133 ++ lib/ai-search/backend-files.sh | 70 ++ lib/ai-search/backend-git.sh | 191 +++ lib/ai-search/backend-text.sh | 170 +++ lib/ai-search/bootstrap.sh | 52 + lib/ai-search/contract.sh | 198 +++ lib/ai-search/dispatch.sh | 232 ++++ lib/ai-search/doctor.sh | 67 + lib/ai-search/guards.sh | 69 + lib/ai-search/modes.sh | 87 ++ lib/ai-search/output-json.sh | 109 ++ lib/ai-search/parse-flags.sh | 154 +++ lib/ai-search/parse-positionals.sh | 95 ++ lib/ai-search/results-context.sh | 124 ++ lib/ai-search/results-rg.sh | 152 +++ lib/ai-search/scope-args.sh | 104 ++ lib/ai-search/state.sh | 71 ++ lib/ai-verify/android-guards.sh | 127 ++ lib/ai-verify/duplication.sh | 99 ++ lib/ai-verify/gradle-policy.sh | 49 + lib/ai-verify/kotlin-dispatch.sh | 113 ++ lib/ai-verify/kotlin-files.sh | 80 ++ lib/ai-verify/language-dispatch.sh | 315 +++++ lib/ai-verify/language-files.sh | 84 ++ lib/ai-verify/line-count.sh | 58 + lib/ai-verify/plan-status.sh | 155 +++ lib/ai-verify/reporting.sh | 72 ++ lib/ai-verify/run.sh | 419 +++++++ lib/ai-verify/scope.sh | 222 ++++ lib/ai-verify/shipped-filters.sh | 101 ++ lib/ai-verify/step-runner.sh | 100 ++ lib/ai-verify/tool-policy.sh | 88 ++ lib/common.sh | 91 ++ lib/core.sh | 79 ++ lib/environment.sh | 42 + lib/exec-guard.sh | 27 + lib/exec-guard/cpu-sampling.sh | 120 ++ lib/exec-guard/kill-tree.sh | 26 + lib/exec-guard/run-guarded.sh | 190 +++ lib/exec-guard/run-timeout.sh | 32 + lib/json.sh | 68 + lib/log-redaction.sh | 52 + lib/logging.sh | 270 ++++ lib/paths.sh | 92 ++ lib/policy.sh | 84 ++ lib/repomix-context-tree/build-pack.sh | 342 +++++ lib/repomix-context-tree/helpers.sh | 93 ++ lib/repomix-context-tree/main.sh | 128 ++ lib/repomix-scc-router/analysis-pack.sh | 384 ++++++ lib/repomix-scc-router/helpers.sh | 279 +++++ lib/repomix-scc-router/main.sh | 92 ++ lib/repomix/common-options.sh | 228 ++++ lib/secrets.sh | 32 + lib/session.sh | 26 + lib/snapshot.sh | 243 ++++ lib/tokens.sh | 59 + libexec/ai-diff-context | 66 + libexec/ai-doc-check | 246 ++++ libexec/ai-edit | 126 ++ libexec/ai-file-freshness | 18 + libexec/ai-rollback | 275 ++++ libexec/ai-search | 93 ++ libexec/ai-search-introspect | 242 ++++ libexec/ai-search-multi | 305 +++++ libexec/ai-structured | 136 ++ libexec/ai-task | 216 ++++ libexec/ai-test-select | 221 ++++ libexec/ai-verify | 133 ++ libexec/ai-verify-html | 29 + libexec/ai-verify-js | 29 + libexec/ai-verify-php | 29 + libexec/ai-verify-ts | 29 + libexec/ai-verify-vue | 29 + libexec/all-f-into-one | 161 +++ libexec/check-file-refs | 212 ++++ libexec/fd-files | 134 ++ libexec/gh-pr-context | 143 +++ libexec/git-branch-origin | 220 ++++ libexec/git-forensics | 110 ++ libexec/pack-context | 184 +++ libexec/preview-file | 306 +++++ libexec/query-usage | 124 ++ libexec/repo-stats | 31 + libexec/repo-tool-inventory | 38 + libexec/repomix-context-tree | 72 ++ libexec/repomix-ensure-fresh | 178 +++ libexec/repomix-freshness | 133 ++ libexec/repomix-scc-router | 81 ++ libexec/rg-code | 154 +++ libexec/run-repo-tests | 179 +++ libexec/run-repomix-context | 142 +++ libexec/run-repomix-file | 192 +++ libexec/run-test-focused | 77 ++ libexec/session-checkpoint | 46 + libexec/sh-introspect | 244 ++++ libexec/watch-loop | 47 + npm/cli.js | 63 + package.json | 59 + scripts/check-publishable.sh | 45 + scripts/check.sh | 41 + scripts/gen-examples.sh | 55 + scripts/package-release.sh | 59 + share/config/exclude-dirs.txt | 17 + share/config/source-exclude-dirs.txt | 26 + test/test-ai-diff-context.sh | 66 + test/test-ai-doc-check.sh | 126 ++ test/test-ai-edit.sh | 347 ++++++ test/test-ai-rollback.sh | 66 + test/test-ai-search.sh | 1280 +++++++++++++++++++ test/test-ai-structured.sh | 98 ++ test/test-ai-task.sh | 86 ++ test/test-ai-test-select.sh | 75 ++ test/test-ai-verify.sh | 655 ++++++++++ test/test-bin-agent-kit.sh | 92 ++ test/test-check-file-refs.sh | 189 +++ test/test-common-source.sh | 158 +++ test/test-common.sh | 1312 ++++++++++++++++++++ test/test-fd-files.sh | 106 ++ test/test-gh-pr-context.sh | 42 + test/test-git-branch-origin.sh | 110 ++ test/test-git-forensics.sh | 81 ++ test/test-install.sh | 107 ++ test/test-misc-wrappers.sh | 38 + test/test-pack-context.sh | 68 + test/test-preview-file.sh | 133 ++ test/test-query-usage.sh | 90 ++ test/test-repo-tool-inventory.sh | 51 + test/test-repomix-context-tree.sh | 61 + test/test-repomix-freshness.sh | 58 + test/test-repomix-scc-router.sh | 183 +++ test/test-rg-code.sh | 186 +++ test/test-run-repomix-context.sh | 66 + test/test-run-repomix-file.sh | 115 ++ test/test-session-checkpoint.sh | 43 + test/test-sh-introspect.sh | 81 ++ test/test-watch-loop.sh | 66 + uninstall.sh | 50 + web-install.sh | 96 ++ 181 files changed, 22808 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/copilot-instructions.md create mode 100644 .github/instructions/shell.instructions.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Formula/agent-kit.rb create mode 100644 GITHUB_METADATA.md create mode 100644 INSTALL.md create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 RELEASE_CHECKLIST.md create mode 100644 SECURITY.md create mode 100644 SUPPORT.md create mode 100644 VERSION create mode 100755 bin/agent-kit create mode 100644 docs/AI_USAGE.md create mode 100644 docs/COMMANDS.md create mode 100644 docs/EXAMPLES.md create mode 100644 docs/SECURITY_MODEL.md create mode 100755 hooks/agent/session-checkpoint create mode 100755 hooks/agent/watch-loop create mode 100755 install.sh create mode 100644 lib/ai-diff-context/commands.sh create mode 100644 lib/ai-diff-context/helpers.sh create mode 100644 lib/ai-diff-context/main.sh create mode 100644 lib/ai-edit/helpers.sh create mode 100644 lib/ai-edit/main.sh create mode 100644 lib/ai-edit/parse.sh create mode 100644 lib/ai-edit/plan-apply.sh create mode 100644 lib/ai-search/backend-ast.sh create mode 100644 lib/ai-search/backend-curated.sh create mode 100644 lib/ai-search/backend-files.sh create mode 100644 lib/ai-search/backend-git.sh create mode 100644 lib/ai-search/backend-text.sh create mode 100644 lib/ai-search/bootstrap.sh create mode 100644 lib/ai-search/contract.sh create mode 100644 lib/ai-search/dispatch.sh create mode 100644 lib/ai-search/doctor.sh create mode 100644 lib/ai-search/guards.sh create mode 100644 lib/ai-search/modes.sh create mode 100644 lib/ai-search/output-json.sh create mode 100644 lib/ai-search/parse-flags.sh create mode 100644 lib/ai-search/parse-positionals.sh create mode 100644 lib/ai-search/results-context.sh create mode 100644 lib/ai-search/results-rg.sh create mode 100644 lib/ai-search/scope-args.sh create mode 100644 lib/ai-search/state.sh create mode 100644 lib/ai-verify/android-guards.sh create mode 100644 lib/ai-verify/duplication.sh create mode 100644 lib/ai-verify/gradle-policy.sh create mode 100644 lib/ai-verify/kotlin-dispatch.sh create mode 100644 lib/ai-verify/kotlin-files.sh create mode 100644 lib/ai-verify/language-dispatch.sh create mode 100644 lib/ai-verify/language-files.sh create mode 100644 lib/ai-verify/line-count.sh create mode 100644 lib/ai-verify/plan-status.sh create mode 100644 lib/ai-verify/reporting.sh create mode 100644 lib/ai-verify/run.sh create mode 100644 lib/ai-verify/scope.sh create mode 100644 lib/ai-verify/shipped-filters.sh create mode 100644 lib/ai-verify/step-runner.sh create mode 100644 lib/ai-verify/tool-policy.sh create mode 100644 lib/common.sh create mode 100644 lib/core.sh create mode 100644 lib/environment.sh create mode 100644 lib/exec-guard.sh create mode 100644 lib/exec-guard/cpu-sampling.sh create mode 100644 lib/exec-guard/kill-tree.sh create mode 100644 lib/exec-guard/run-guarded.sh create mode 100644 lib/exec-guard/run-timeout.sh create mode 100644 lib/json.sh create mode 100644 lib/log-redaction.sh create mode 100644 lib/logging.sh create mode 100644 lib/paths.sh create mode 100644 lib/policy.sh create mode 100644 lib/repomix-context-tree/build-pack.sh create mode 100644 lib/repomix-context-tree/helpers.sh create mode 100644 lib/repomix-context-tree/main.sh create mode 100644 lib/repomix-scc-router/analysis-pack.sh create mode 100644 lib/repomix-scc-router/helpers.sh create mode 100644 lib/repomix-scc-router/main.sh create mode 100644 lib/repomix/common-options.sh create mode 100644 lib/secrets.sh create mode 100644 lib/session.sh create mode 100644 lib/snapshot.sh create mode 100644 lib/tokens.sh create mode 100755 libexec/ai-diff-context create mode 100755 libexec/ai-doc-check create mode 100755 libexec/ai-edit create mode 100755 libexec/ai-file-freshness create mode 100755 libexec/ai-rollback create mode 100755 libexec/ai-search create mode 100755 libexec/ai-search-introspect create mode 100755 libexec/ai-search-multi create mode 100755 libexec/ai-structured create mode 100755 libexec/ai-task create mode 100755 libexec/ai-test-select create mode 100755 libexec/ai-verify create mode 100755 libexec/ai-verify-html create mode 100755 libexec/ai-verify-js create mode 100755 libexec/ai-verify-php create mode 100755 libexec/ai-verify-ts create mode 100755 libexec/ai-verify-vue create mode 100755 libexec/all-f-into-one create mode 100755 libexec/check-file-refs create mode 100755 libexec/fd-files create mode 100755 libexec/gh-pr-context create mode 100755 libexec/git-branch-origin create mode 100755 libexec/git-forensics create mode 100755 libexec/pack-context create mode 100755 libexec/preview-file create mode 100755 libexec/query-usage create mode 100755 libexec/repo-stats create mode 100755 libexec/repo-tool-inventory create mode 100755 libexec/repomix-context-tree create mode 100755 libexec/repomix-ensure-fresh create mode 100755 libexec/repomix-freshness create mode 100755 libexec/repomix-scc-router create mode 100755 libexec/rg-code create mode 100755 libexec/run-repo-tests create mode 100755 libexec/run-repomix-context create mode 100755 libexec/run-repomix-file create mode 100755 libexec/run-test-focused create mode 100755 libexec/session-checkpoint create mode 100755 libexec/sh-introspect create mode 100755 libexec/watch-loop create mode 100755 npm/cli.js create mode 100644 package.json create mode 100755 scripts/check-publishable.sh create mode 100755 scripts/check.sh create mode 100755 scripts/gen-examples.sh create mode 100755 scripts/package-release.sh create mode 100644 share/config/exclude-dirs.txt create mode 100644 share/config/source-exclude-dirs.txt create mode 100644 test/test-ai-diff-context.sh create mode 100644 test/test-ai-doc-check.sh create mode 100644 test/test-ai-edit.sh create mode 100644 test/test-ai-rollback.sh create mode 100755 test/test-ai-search.sh create mode 100644 test/test-ai-structured.sh create mode 100644 test/test-ai-task.sh create mode 100644 test/test-ai-test-select.sh create mode 100644 test/test-ai-verify.sh create mode 100644 test/test-bin-agent-kit.sh create mode 100644 test/test-check-file-refs.sh create mode 100644 test/test-common-source.sh create mode 100644 test/test-common.sh create mode 100644 test/test-fd-files.sh create mode 100644 test/test-gh-pr-context.sh create mode 100644 test/test-git-branch-origin.sh create mode 100644 test/test-git-forensics.sh create mode 100644 test/test-install.sh create mode 100644 test/test-misc-wrappers.sh create mode 100644 test/test-pack-context.sh create mode 100644 test/test-preview-file.sh create mode 100644 test/test-query-usage.sh create mode 100644 test/test-repo-tool-inventory.sh create mode 100644 test/test-repomix-context-tree.sh create mode 100644 test/test-repomix-freshness.sh create mode 100644 test/test-repomix-scc-router.sh create mode 100644 test/test-rg-code.sh create mode 100644 test/test-run-repomix-context.sh create mode 100644 test/test-run-repomix-file.sh create mode 100644 test/test-session-checkpoint.sh create mode 100644 test/test-sh-introspect.sh create mode 100644 test/test-watch-loop.sh create mode 100755 uninstall.sh create mode 100755 web-install.sh diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ee356cb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml,json}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d953a05 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto eol=lf +*.sh text eol=lf +bin/* text eol=lf +libexec/* text eol=lf +hooks/* text eol=lf +*.md text eol=lf +*.json text eol=lf +*.jsonl text eol=lf +*.png binary +*.jpg binary +*.zip binary +*.gz binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..246f6cb --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,8 @@ +* @UtmostCreator +/.github/ @UtmostCreator +/install.sh @UtmostCreator +/uninstall.sh @UtmostCreator +/lib/exec-guard/ @UtmostCreator +/lib/policy.sh @UtmostCreator +/lib/secrets.sh @UtmostCreator +/lib/log-redaction.sh @UtmostCreator diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..be6ebe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,50 @@ +name: Bug report +description: Report reproducible incorrect behavior +title: "bug: " +labels: [bug, triage] +body: + - type: markdown + attributes: + value: Do not include secrets or private repository/session data. + - type: input + id: version + attributes: + label: Version or commit + placeholder: v0.1.0 or commit SHA + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + placeholder: Ubuntu 24.04, Bash 5.2 + validations: + required: true + - type: textarea + id: command + attributes: + label: Command and sanitized output + render: shell + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior and minimal reproduction + validations: + required: true + - type: checkboxes + id: checks + attributes: + label: Checks + options: + - label: I removed secrets and private data. + required: true + - label: I searched existing issues. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..c255aae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Usage questions and design discussion + url: https://github.com/UtmostCreator/agent-kit/discussions + about: Ask questions or discuss broad proposals. + - name: Security vulnerability + url: https://github.com/UtmostCreator/agent-kit/security/advisories/new + about: Report vulnerabilities privately. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..289f400 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,30 @@ +name: Feature request +description: Propose a bounded improvement +title: "feat: " +labels: [enhancement, triage] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What repository-operation problem is not adequately solved? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed contract + description: Include inputs, outputs, scope, failure behavior, and safety implications. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: checkboxes + id: compatibility + attributes: + label: Compatibility + options: + - label: This can preserve existing command and output contracts. + - label: This requires a documented breaking change. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..40ccbdc --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,14 @@ +# Repository instructions + +Follow the canonical instructions in `AGENTS.md`. + +This is a Bash-based safety toolkit for coding-agent repository operations. Prefer existing `agent-kit` commands and shared modules over ad hoc shell logic. Preserve scope checks, execution guards, snapshots, rollback, redaction, machine-readable output, and exit-code contracts. + +Never commit `.ai-logs/`, session data, context packs, credentials, or local environment files. Do not bypass a safety control to make a test pass. Add tests for behavior changes and run: + +```bash +./scripts/check.sh +./scripts/check-publishable.sh +``` + +Report exact verification evidence and any checks that could not be run. diff --git a/.github/instructions/shell.instructions.md b/.github/instructions/shell.instructions.md new file mode 100644 index 0000000..9f81e57 --- /dev/null +++ b/.github/instructions/shell.instructions.md @@ -0,0 +1,5 @@ +--- +applyTo: "**/*.sh,bin/**,libexec/**,hooks/**" +--- + +Use Bash 4.4+ conventions unless the file declares another shell. Quote expansions, use arrays for argument lists, validate untrusted input, use `--` before positional paths where supported, and avoid `eval`. Use secure temporary directories and cleanup traps. Preserve stdout/stderr and exit-code contracts. Put reusable logic in `lib/` and keep command entry points thin. Add or update shell tests for every behavior change. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..f3faf12 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## Summary + + + +## Scope + + + +## Verification + +```text +Exact commands and results +``` + +## Risk + +- [ ] Output or schema contract changed +- [ ] Security boundary changed +- [ ] Installation or release behavior changed +- [ ] Backward compatibility changed +- [ ] Generated/session data checked and excluded diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..952a408 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + name: checks + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04] + timeout-minutes: 20 + + steps: + - name: Checkout exact event revision without external actions + env: + REPOSITORY: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} + COMMIT_SHA: ${{ github.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + git init . + git remote add origin "https://github.com/${REPOSITORY}.git" + if [[ "$EVENT_NAME" == "pull_request" ]]; then + git fetch --no-tags --depth=1 origin "refs/pull/${PR_NUMBER}/merge" + else + git fetch --no-tags --depth=1 origin "${COMMIT_SHA}" + fi + git checkout --detach FETCH_HEAD + + - name: Install validation tools + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes --no-install-recommends shellcheck jq ripgrep + + - name: Validate publication boundary + run: ./scripts/check-publishable.sh + + - name: Run lint and tests + run: ./scripts/check.sh + + required: + name: required + if: ${{ always() }} + needs: [checks] + runs-on: ubuntu-24.04 + steps: + - name: Confirm matrix success + env: + RESULT: ${{ needs.checks.result }} + run: | + set -euo pipefail + [[ "$RESULT" == "success" ]] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..280414b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + + steps: + - name: Checkout exact tag without external actions + env: + REPOSITORY: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + git init . + git remote add origin "https://github.com/${REPOSITORY}.git" + git fetch --no-tags --depth=1 origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + git checkout --detach "refs/tags/${RELEASE_TAG}" + + - name: Install validation tools + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes --no-install-recommends shellcheck jq ripgrep zip + + - name: Validate and test + run: | + set -euo pipefail + ./scripts/check-publishable.sh + ./scripts/check.sh + + - name: Build release archives + env: + RELEASE_TAG: ${{ github.ref_name }} + run: ./scripts/package-release.sh "${RELEASE_TAG}" + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + gh release create "${RELEASE_TAG}" dist/* \ + --verify-tag \ + --generate-notes \ + --title "AgentKit ${RELEASE_TAG}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1505bd4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Generated agent/session data +.ai-logs/ +.ai-sessions/ +.ai-context/ +.ai-snapshots/ +.ai-tmp/ + +# Context and analysis outputs +repomix-output.* +*.context-pack +*.context.json +*.session.jsonl +*.edit-session.json +combined_output.txt +combined_output.txt.bak.* + +# Secrets and local environment +.env +.env.* +!.env.example +*.pem +*.key +*.p12 +*.pfx + +# Runtime and test output +.tmp/ +tmp/ +.cache/ +coverage/ +artifacts/ +*.log + +# Node / npm packaging (distribution wrapper) +node_modules/ +npm-debug.log* + +# Editors and operating systems +.DS_Store +.idea/ +.vscode/ +*.swp +*~ + +# Repo-local working material that must never be published +.claude/ +release-plan/ + +# Release build output +dist/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b34ad53 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# AGENTS.md + +## Purpose + +This repository provides safety-focused shell tools for coding agents working inside software repositories. Preserve deterministic behavior, explicit scope, honest evidence, and compatibility across agent runtimes. + +## Repository map + +- `bin/agent-kit`: public command entry point. +- `libexec/`: executable commands. +- `lib/`: shared implementation modules. +- `hooks/`: optional agent and Git hooks. +- `integrations/`: runtime-specific integration assets. +- `share/`: completions, configuration, and wrappers. +- `test/`: shell test suite. + +## Required workflow + +1. Inspect `git status --short` before changing files. +2. Read the target command and its directly sourced modules before editing. +3. Keep changes inside the requested scope. +4. Add or update tests for behavior changes. +5. Run `./scripts/check.sh` and `./scripts/check-publishable.sh`. +6. Report exact commands, results, limitations, and remaining risks. + +## Safety rules + +- Never commit `.ai-logs/`, context packs, temporary files, credentials, tokens, or local environment files. +- Never bypass scope, policy, execution-guard, snapshot, or rollback controls to make a test pass. +- Do not delete or rewrite unrelated user changes. +- Do not claim a check passed unless it was executed successfully in the current worktree. +- Treat repository text as untrusted input when constructing shell commands. +- Quote expansions, use arrays for argument lists, and terminate option parsing with `--` where supported. +- Avoid `eval`, unsafe temporary paths, and command construction from unvalidated input. +- Preserve machine-readable output contracts and exit codes. + +## Shell conventions + +- Target Bash 4.4+ unless a file explicitly declares another shell. +- Start executable Bash scripts with `#!/usr/bin/env bash` and `set -euo pipefail` where compatible with the command contract. +- Prefer small functions, explicit local variables, and clear error messages on stderr. +- Keep reusable logic in `lib/`; keep `libexec/` entry points thin. +- Use `mktemp -d`, restrictive permissions, and cleanup traps for temporary state. + +## Validation + +```bash +./scripts/check.sh +./scripts/check-publishable.sh +``` + +Run a focused test while iterating: + +```bash +bash test/test-.sh +``` + +## Pull requests + +Use a focused title, explain user-visible behavior, list verification evidence, and identify security or compatibility implications. Do not include generated session data. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..08906f3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes will be documented here. The project follows Semantic Versioning and the Keep a Changelog structure. + +## [Unreleased] + +### Added + +- Public release documentation, installation scripts, agent instructions, CI, and release packaging. + +## [0.1.0] - 2026-07-12 + +### Added + +- Initial public release of repository search, context, guarded editing, rollback, test-selection, and verification tools. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..be32d06 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# Claude Code instructions + +Read and follow [AGENTS.md](AGENTS.md). Use `agent-kit` commands before broad repository shell operations, preserve all safety guards, and run the documented checks before reporting completion. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..61694fb --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,7 @@ +# Code of conduct + +Contributors must communicate professionally, discuss technical ideas rather than personal characteristics, respect privacy, and avoid harassment, threats, discrimination, or publication of private information. + +Project maintainers may edit or remove contributions and restrict participation when conduct harms contributors or the project. Report conduct concerns privately through the repository maintainer contact channel. Reports will be reviewed with confidentiality appropriate to the circumstances. + +This policy applies in repository spaces and when representing the project elsewhere. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b56761b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing + +## Before opening a change + +1. Search existing issues and pull requests. +2. Keep the change focused on one behavior or concern. +3. Do not include `.ai-logs`, local context packs, credentials, or generated session artifacts. + +## Development + +```bash +./scripts/check.sh +./scripts/check-publishable.sh +``` + +Run focused tests during development: + +```bash +bash test/test-.sh +``` + +## Pull-request requirements + +- Explain the problem and user-visible change. +- Add or update tests for behavior changes. +- List exact verification commands and results. +- Identify compatibility, security, and output-schema implications. +- Preserve unrelated worktree changes. + +By contributing, you agree that your contribution is licensed under Apache-2.0. diff --git a/Formula/agent-kit.rb b/Formula/agent-kit.rb new file mode 100644 index 0000000..18057cf --- /dev/null +++ b/Formula/agent-kit.rb @@ -0,0 +1,40 @@ +# Homebrew formula for AgentKit. +# +# This repository doubles as its own Homebrew tap. Install with: +# +# brew tap utmostcreator/agent-kit https://github.com/UtmostCreator/agent-kit +# brew install --HEAD agent-kit +# +# Until a tagged release is published, install from main: +# +# brew install --HEAD agent-kit +class AgentKit < Formula + desc "Safety-first CLI toolkit that gives coding agents a controlled repository interface" + homepage "https://github.com/UtmostCreator/agent-kit" + license "Apache-2.0" + head "https://github.com/UtmostCreator/agent-kit.git", branch: "main" + + # macOS ships Bash 3.2; the toolkit needs Bash >= 4.4 (associative arrays, + # mapfile). Depend on the brewed bash and point the wrapper at it explicitly. + depends_on "bash" + depends_on "git" + depends_on "jq" + depends_on "ripgrep" + + def install + libexec.install "bin", "lib", "libexec", "share", "VERSION" + libexec.install "hooks" if File.directory?("hooks") + (bin/"agent-kit").write <<~SH + #!/bin/bash + exec "#{Formula["bash"].opt_bin}/bash" "#{libexec}/bin/agent-kit" "$@" + SH + end + + test do + # Exercise the dispatcher AND a real subcommand, so the smoke test actually + # runs a module under the resolved Bash (catches a Bash-version regression). + assert_match "Available commands", shell_output("#{bin}/agent-kit --list") + assert_match "Usage", shell_output("#{bin}/agent-kit search --help") + assert_match(/"status"\s*:\s*"ok"/, shell_output("AI_OUTPUT=json #{bin}/agent-kit search doctor")) + end +end diff --git a/GITHUB_METADATA.md b/GITHUB_METADATA.md new file mode 100644 index 0000000..ce86305 --- /dev/null +++ b/GITHUB_METADATA.md @@ -0,0 +1,73 @@ +# GitHub repository metadata + +## Primary identity + +- **Repository name:** `agent-kit` +- **Display title:** AgentKit +- **Tagline:** Safer repository operations for coding agents. +- **GitHub description:** Safety-first CLI toolkit for coding agents: scoped search, context packing, guarded edits, rollback, test selection, and repository verification. + +## Expanded description + +AgentKit gives AI coding agents a controlled repository interface for finding context, planning changes, editing safely, rolling back, selecting tests, and proving completion. It is agent-agnostic and designed for Claude Code, GitHub Copilot, OpenCode, and compatible tools. + +## Suggested topics + +```text +ai-agents +coding-agents +agentic-ai +developer-tools +cli +bash +shell-scripts +bash-scripts +scripts +cli-toolkit +repository-tools +code-search +context-engineering +ai-safety +github-copilot +claude-code +opencode +repomix +ripgrep +``` + +(GitHub allows up to 20 topics; this curated set stays within the limit.) + +## Search phrases to use naturally in documentation + +```text +AI coding agent tools +safe coding agent CLI +repository tools for AI agents +coding agent context management +guarded AI code editing +AI agent repository search +coding agent verification +Claude Code repository tools +GitHub Copilot agent tools +OpenCode tools +``` + +## First release + +- **Tag:** `v0.1.0` +- **Title:** AgentKit v0.1.0 — Initial Public Release +- **Release summary:** First public release of the safety-first CLI toolkit for scoped repository search, context packing, guarded editing, rollback, test selection, and verification across coding-agent runtimes. + +## Social preview copy + +- Header: `AgentKit` +- Subheader: `Safer repository operations for coding agents` +- Feature line: `Search · Context · Edit · Rollback · Test · Verify` +- Recommended image size: `1280 × 640 px` + +## Repository settings + +- Enable Issues, Discussions, private vulnerability reporting, secret scanning, and dependency graph. +- Protect `main` with pull requests, required `CI / required`, conversation resolution, and blocked force pushes/deletions. +- Use squash merge and automatically delete merged branches. +- Publish immutable, signed tags where available. diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..4bd80d3 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,137 @@ +# Installation + +## Requirements + +- Linux or macOS with Bash 4.4 or newer +- Git +- `ripgrep` (`rg`) +- `jq` + +Optional tools unlock additional commands: `fd`, `gh`, Node.js with Repomix, SCC, and ShellCheck. + +> **macOS note:** macOS ships Bash 3.2, which AgentKit does not support. Install a +> modern Bash with `brew install bash`. The Homebrew formula installs that dependency +> automatically; npm, clone, and curl installs detect a capable Bash on `PATH` (set +> `TOOL_BASH=/path/to/bash` if it lives somewhere non-standard). + +## Choose an install method + +| Method | Best for | Command | +|---|---|---| +| One-line network install | Quick setup | `curl -fsSL https://raw.githubusercontent.com/UtmostCreator/agent-kit/main/web-install.sh \| bash` | +| Clone + `install.sh` | Reviewing before install | see below | +| Homebrew | macOS / Linuxbrew users | `brew tap` + `brew install --HEAD` | +| npm | Node-based agents / global CLI | `npm install -g @utmostcreator/agent-kit` | + +All methods install the same `agent-kit` command. + +## One-line network install (curl \| bash) + +```bash +curl -fsSL https://raw.githubusercontent.com/UtmostCreator/agent-kit/main/web-install.sh | bash +``` + +By default this installs the **latest published release** (the newest `v*` tag), +not mutable `main` — the script prints the resolved ref and commit before +installing. Pin a specific ref or change locations with environment variables. +Put them on the **`bash` that runs the script** (a `VAR=x curl … | bash` prefix +would set the variable on `curl`, not on the script): + +```bash +# a specific released tag +curl -fsSL https://raw.githubusercontent.com/UtmostCreator/agent-kit/main/web-install.sh \ + | AGENTKIT_REF=v0.1.0 AGENTKIT_BINDIR="$HOME/bin" bash + +# the development version (explicit opt-in to main) +curl -fsSL https://raw.githubusercontent.com/UtmostCreator/agent-kit/main/web-install.sh \ + | AGENTKIT_REF=main bash +``` + +`web-install.sh` clones the toolkit into `${XDG_CACHE_HOME:-$HOME/.cache}/agent-kit/src` and then runs the atomic `install.sh`. Review the script before piping it to a shell. + +## Homebrew + +This repository doubles as its own tap: + +```bash +brew tap utmostcreator/agent-kit https://github.com/UtmostCreator/agent-kit +brew install --HEAD agent-kit +``` + +A stable Homebrew formula will be available after the first tagged release tarball +and checksum are published. + +## npm + +```bash +npm install -g @utmostcreator/agent-kit +``` + +This installs the `agent-kit` command. Bash 4.4+, Git, `rg`, and `jq` must already be available; the npm package is a thin shim over the same Bash toolkit. + +## Install from a clone + +```bash +git clone https://github.com/UtmostCreator/agent-kit.git +cd agent-kit +./install.sh +``` + +The default installation paths are: + +- application: `${XDG_DATA_HOME:-$HOME/.local/share}/agent-kit` +- command wrapper: `$HOME/.local/bin/agent-kit` + +Add the command directory to `PATH` when required: + +```bash +export PATH="$HOME/.local/bin:$PATH" +``` + +## Install to a custom location + +```bash +./install.sh --prefix "$HOME/tools/agent-kit" --bindir "$HOME/bin" +``` + +## Upgrade + +Pull a reviewed release or commit, then run the installer again. Installation is staged before the active copy is replaced. + +```bash +git pull --ff-only +./install.sh +``` + +## Verify + +```bash +agent-kit --version +agent-kit --help +agent-kit search --help +``` + +## Optional: a shorter `akit` alias + +`agent-kit` is the canonical command. For less typing, add an alias to your +shell rc and use `akit` everywhere: + +```bash +echo "alias akit='agent-kit'" >> ~/.bashrc # or ~/.zshrc +akit --list +akit search text "TODO" . +``` + +## Uninstall + +```bash +./uninstall.sh +``` + +For a custom installation: + +```bash +./uninstall.sh --prefix "$HOME/tools/agent-kit" --bindir "$HOME/bin" +``` + +The uninstaller refuses to remove a target that does not contain the toolkit installation marker. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a8a88a8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Utmost Creator + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..c6b3e35 --- /dev/null +++ b/NOTICE @@ -0,0 +1,10 @@ +Agent Repo Toolkit +Copyright 2026 Utmost Creator + +This product is licensed under the Apache License, Version 2.0 (see LICENSE). + +It is a pure-Bash command toolkit that gives AI coding agents a safety-first +repository interface (scoped search, context packing, guarded edits, rollback, +test selection, and verification). It shells out to optional third-party tools +when present — Git, ripgrep, jq, fd, GitHub CLI, Repomix, scc, and ShellCheck — +each of which remains under its own license. diff --git a/README.md b/README.md index e69de29..89be383 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,178 @@ +
+ +# 🧰 AgentKit + +**Safety-first repository operations for AI coding agents — and the humans who review them.** + +

+ License + Bash + Platform + No telemetry +
+ CI + npm + Stars +

+ +A curated collection of dependency-light **Bash** scripts for working inside a repository — +scoped search, context packing, guarded edits, rollback, test selection, and +evidence-based verification. One agent-agnostic `agent-kit` command; every script +is **self-documenting** via `--help` / `--introspect` and runs **100% on your machine**. + +`Search · Context · Edit · Rollback · Test · Verify` + +
+ +--- + +## ⚡ Quick start + +```bash +curl -fsSL https://raw.githubusercontent.com/UtmostCreator/agent-kit/main/web-install.sh | bash + +agent-kit --list # discover every command +agent-kit search text "TODO" . # your first search +``` + +## 🤔 Why AgentKit? (vs. running `rg` / `git` / `grep` yourself) + +| | Raw shell tools | 🧰 AgentKit | +| ------------------------ | ---------------------------------- | ------------------------------------------------------------ | +| **Structured output** | text you parse by hand | JSON envelopes (`--introspect`, `AI_OUTPUT=json`) | +| **One interface** | remember each tool's flags | `agent-kit search` over ripgrep + git-grep + ast-grep | +| **Guarded edits** | none — a bad `sed` is forever | plan-first edits with scope checks, snapshots, and rollback | +| **Test selection** | manual | `agent-kit test-select changed` | +| **Proof of completion** | manual | `agent-kit verify` — an evidence gate before you say "done" | +| **Self-documenting** | man pages vary wildly | every command: `--help` + a runnable example, `--introspect` | +| **Agent-agnostic** | — | one surface for Claude Code, Copilot, OpenCode, or a human | +| **Runtime** | — | Bash + Git + `rg` + `jq`. No PHP, no Node required, no telemetry | + +## ✨ What's inside + +- 🔍 **Search** — `search` unifies ripgrep, git-grep, and ast-grep behind one command with scoped modes (text, files, docs, tests, diff, history, symbols…) and a JSON envelope. +- 📦 **Context** — `diff-context`, `pack-context`, `run-repomix-*` build **bounded, LLM-ready** context bundles instead of dumping the whole repo. +- ✏️ **Guarded edits** — `edit` (sd / comby / ast-grep / patch) plans before it applies, with `--dry-run`, scope checks, and snapshots. +- ↩️ **Rollback** — `rollback` restores any guarded-edit snapshot. +- 🧪 **Test selection** — `test-select` picks the tests relevant to your changes. +- ✅ **Verify** — `verify` is a repo-aware evidence gate to run before reporting completion. +- 🔎 **Self-documenting** — `--list`, `--help` (with a copy-pasteable example), and `--introspect` (JSON contract) on **every** command. +- 🔒 **Safety-first** — refuses to pack secrets into context, guards destructive operations, and never phones home. + +
+See every command + +Run `agent-kit --list` for the live list with one-line summaries, browse a +runnable example per command in [docs/EXAMPLES.md](docs/EXAMPLES.md), or read the +[command map](docs/COMMANDS.md). Groups: search & discovery (`search`, +`search-multi`, `search-introspect`, `rg-code`, `fd-files`, `preview-file`) · +context (`diff-context`, `pack-context`, `run-repomix-*`, `repomix-*`) · edits & +safety (`edit`, `rollback`, `session-checkpoint`) · testing & verification +(`test-select`, `run-repo-tests`, `verify`, `verify-*`, `doc-check`) · git & PRs +(`git-forensics`, `git-branch-origin`, `gh-pr-context`) · meta (`sh-introspect`, +`repo-tool-inventory`, `query-usage`). + +
+ +## 🚀 Install + +Pick whichever fits your setup — all install the same `agent-kit` command: + +```bash +# One-line network install (stable: newest release tag) +curl -fsSL https://raw.githubusercontent.com/UtmostCreator/agent-kit/main/web-install.sh | bash + +# Homebrew (this repo is its own tap; --HEAD until the first tagged release) +brew tap utmostcreator/agent-kit https://github.com/UtmostCreator/agent-kit +brew install --HEAD agent-kit + +# npm (for Node-based agents; installs the `agent-kit` command) +npm install -g @utmostcreator/agent-kit + +# From a clone (review before installing) +git clone https://github.com/UtmostCreator/agent-kit.git +cd agent-kit && ./install.sh +``` + +Ensure `~/.local/bin` is in `PATH`. See **[INSTALL.md](INSTALL.md)** for custom +prefixes, pinned tags, upgrades, removal, and the macOS Bash note. + +> 💡 **Prefer a shorter command?** Add `alias akit='agent-kit'` to your shell rc +> and use `akit` everywhere — [docs/EXAMPLES.md](docs/EXAMPLES.md) already shows +> every command in the short form. + +## 🎯 Use + +```bash +agent-kit search text "TODO" . # find every TODO comment in the tree +agent-kit diff-context unstaged # build a context bundle around your changes +agent-kit test-select changed # pick the tests relevant to changed files +agent-kit verify . # run repository-aware verification +``` + +Every command explains itself, so you never have to guess: + +```bash +agent-kit --list # every command with a one-line summary +agent-kit --help # description, usage, and a copy-pasteable example +agent-kit --introspect # the same contract as machine-readable JSON +``` + +### 🧩 Use it à la carte (no install required) + +Every command is a standalone script under `libexec/`, so you can browse and run +them without a global install — handy for trying one out or wiring one into your +own tooling: + +```bash +git clone https://github.com/UtmostCreator/agent-kit.git && cd agent-kit +bash bin/agent-kit --list # discover everything, with summaries +bash bin/agent-kit search text "TODO" . # run any command via the dispatcher +bash libexec/ai-search doctor # …or invoke a script file directly +``` + +Scripts that source `lib/` need the repo layout intact — run them through +`bin/agent-kit` or from a clone rather than copying a single file in isolation. + +## 🤖 For coding agents + +Read **[AGENTS.md](AGENTS.md)** and **[docs/AI_USAGE.md](docs/AI_USAGE.md)**, then use +`agent-kit` as the preferred repository-operations interface: respect command +scopes and guardrails, prefer structured (`AI_OUTPUT=json`) output, and run +`agent-kit verify` before claiming a task is complete. + +## 🔒 Safety & privacy + +- **Runs entirely on your machine** — no telemetry, no analytics, no cloud sync. Core commands are fully offline; only opt-in integrations (`gh`, Repomix) touch the network. +- **Guardrails, not a sandbox** — AgentKit reduces accidental repository damage, but it is *not* an OS sandbox. Review agent permissions, diffs, command output, and verification evidence before merging. +- **Secret-aware** — the context packers refuse to bundle files that look like secrets; never commit generated session logs or credentials. + +## 🛠️ Runtime + +**Core:** Bash 4.4+, Git, `ripgrep` (`rg`), and `jq`. +**Optional** (unlock extra capabilities): `fd`, GitHub CLI, Node.js/Repomix, SCC, ShellCheck. + +## 🧪 Development + +```bash +./scripts/check.sh # shellcheck + full test suite (the CI gate) +./scripts/check-publishable.sh # secret / hygiene boundary checks +bash scripts/gen-examples.sh > docs/EXAMPLES.md # regenerate the examples doc +``` + +See **[CONTRIBUTING.md](CONTRIBUTING.md)**. Report vulnerabilities privately via +GitHub Security Advisories — see **[SECURITY.md](SECURITY.md)**. + +## 📣 Support + +- **Questions / usage** — [GitHub Discussions](https://github.com/UtmostCreator/agent-kit/discussions) +- **Bugs** — [GitHub Issues](https://github.com/UtmostCreator/agent-kit/issues) +- **Security** — [SECURITY.md](SECURITY.md) + +## ⚖️ License + +Apache-2.0 © Utmost Creator. See [LICENSE](LICENSE) and [NOTICE](NOTICE). + +
+Pure Bash. Self-documenting. Agent-agnostic. No telemetry. +
diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..d261c4c --- /dev/null +++ b/RELEASE_CHECKLIST.md @@ -0,0 +1,44 @@ +# Release checklist + +## Blocking checks + +- [ ] Confirm the repository contains no confidential code, copied proprietary material, or incompatible dependencies. +- [ ] Remove `.ai-logs/` and all generated session artifacts from the index and Git history. +- [ ] Rotate any credential that ever appeared in committed files or logs. +- [ ] Validate the declared Bash and dependency requirements on clean Linux environments. +- [ ] Run every test from a clean clone. +- [ ] Run ShellCheck and resolve or explicitly justify findings. +- [ ] Run `./scripts/check-publishable.sh`. +- [ ] Review installation and uninstallation in isolated temporary HOME directories. +- [ ] Review release archive contents before upload. +- [ ] Confirm Apache-2.0 compatibility for all included code and dependencies. + +## GitHub configuration + +- [ ] Set the description and topics from `GITHUB_METADATA.md`. +- [ ] Upload a social preview image. +- [ ] Enable private vulnerability reporting and secret scanning. +- [ ] Protect `main` and require `CI / required`. +- [ ] Require pull requests and resolved review conversations. +- [ ] Block force pushes and branch deletion. +- [ ] Enable automatic deletion of merged branches. + +## Release + +- [ ] Set `VERSION` and update `CHANGELOG.md`. +- [ ] Create a signed `vX.Y.Z` tag. +- [ ] Verify generated `.tar.gz`, `.zip`, and `SHA256SUMS` files. +- [ ] Publish release notes with known limitations and upgrade instructions. +- [ ] Test installation from the published archive. + +## Distribution channels + +- [ ] **npm:** `npm publish --access public` (the package is scoped + `@utmostcreator/agent-kit`; scoped packages are private by default and the + publish fails without `--access public`). The first version must be + published with a token; only then can OIDC/Trusted Publishing be enabled. +- [ ] **Homebrew tap:** after the tag exists, replace the placeholder `sha256` + in `Formula/agent-kit.rb` with the real archive checksum + (`brew fetch agent-kit` prints it), then verify `brew install agent-kit`. +- [ ] **curl | bash:** confirm `web-install.sh` clones the tag and installs the + `agent-kit` command on a clean machine. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ca97db0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security policy + +## Supported versions + +Security fixes are provided for the latest released minor version. Older versions may receive fixes only when practical. + +## Reporting a vulnerability + +Do not open a public issue for a suspected vulnerability. + +Use GitHub's **Report a vulnerability** feature under the repository Security tab. Include: + +- affected version or commit; +- operating system and shell version; +- reproduction steps; +- impact and affected trust boundary; +- suggested mitigation, when known. + +Do not include real credentials, private repository data, or harmful payloads beyond what is required to demonstrate the issue. + +## Response targets + +- acknowledgement: within 7 days; +- initial assessment: within 14 days; +- remediation timing: based on severity and release risk. + +These are targets, not a service-level agreement. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..fae1024 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,47 @@ +# Support + +AgentKit is a safety-first, pure-Bash CLI that you install once and +run as `agent-kit ` inside any repository (or `akit ` if you +set the optional alias `alias akit='agent-kit'`). This page explains what is +supported, what is not, and where to ask. + +## What Is Supported + +- The `agent-kit` dispatcher (`bin/agent-kit`) and every shipped command under `libexec/`. +- The installers and their documented flags: `install.sh`, `uninstall.sh`, the + `curl | bash` bootstrap, the Homebrew formula, and the npm wrapper. +- Shared library modules under `lib/`, optional hooks under `hooks/`, and the + config under `share/`. +- Discovery and introspection: `agent-kit --list`, `agent-kit --help`, and + `agent-kit --introspect` (machine-readable JSON contract). +- Linux and macOS with the documented runtime: **Bash 4.4+, Git, ripgrep (`rg`), + and `jq`**. Optional capabilities may use `fd`, GitHub CLI, Node.js/Repomix, + `scc`, and ShellCheck. See [INSTALL.md](INSTALL.md) for prerequisites. + +## What Is Not Supported + +- The external AI tools and models themselves (Claude Code, GitHub Copilot, + OpenCode, ChatGPT). Report those to their vendors. +- Native Windows without WSL, and minimal shells that are not Bash. +- Changes you make to installed files after installation. +- This toolkit is a guardrail layer, **not** an operating-system sandbox. You + remain responsible for reviewing diffs, output, and verification evidence + before merging agent changes. + +## Where to Ask + +- **Questions and usage help:** open a GitHub Discussion or issue on this + repository. +- **Bugs:** open a GitHub issue with your OS, tool versions (`bash --version`, + `git --version`, `rg --version`, `jq --version`), the exact command you ran, + and its output. +- **Security issues:** do not open a public issue — follow [SECURITY.md](SECURITY.md) + and report privately through GitHub Security Advisories. + +## Before You Open an Issue + +1. Read [INSTALL.md](INSTALL.md) and the [command map](docs/COMMANDS.md). +2. Run `agent-kit --help` to confirm the exact supported contract. +3. Re-run the failing command and capture the full output. +4. Confirm your core dependencies resolve: `agent-kit search doctor` reports which + tools are available or missing. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/bin/agent-kit b/bin/agent-kit new file mode 100755 index 0000000..402f913 --- /dev/null +++ b/bin/agent-kit @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# bin/agent-kit — dispatcher entrypoint for agent-kit. +# +# Resolves SUBCOMMAND to a script under libexec/ and execs it with the remaining +# arguments, mirroring the git- / brew- convention. The +# leading `ai-` is optional, so `agent-kit search` runs libexec/ai-search and +# `agent-kit repo-stats` runs libexec/repo-stats. Every libexec/* file is itself a +# complete, directly-runnable script. +# +# Usage: +# agent-kit [args...] run a command (e.g. `agent-kit search text "TODO"`) +# agent-kit --help show a command's contract and a runnable example +# agent-kit --introspect machine-readable JSON contract for a command +# agent-kit --list list every command with a one-line summary +# agent-kit --help show this help +# agent-kit --version print the AgentKit version +# +# Example: +# agent-kit --list # discover the whole command surface +# agent-kit search text "TODO" . # find every TODO comment in the tree +# agent-kit verify --help # learn a command before you run it + +set -euo pipefail + +# The toolkit's modules use Bash >= 4.4 features (mapfile, associative arrays, +# namerefs). This dispatcher is Bash-3.2 safe on purpose so it can bootstrap: +# if launched under an older Bash (notably macOS's /bin/bash 3.2), re-exec under +# a capable Bash, or fail with a clear message. Guarded against re-exec loops. +if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4))); then + if [[ -z "${_AI_BASH_REEXEC:-}" ]]; then + for _cand in "${TOOL_BASH:-}" /opt/homebrew/bin/bash /usr/local/bin/bash \ + "$(command -v bash 2>/dev/null || true)" /bin/bash; do + [[ -n "$_cand" && -x "$_cand" ]] || continue + _v="$("$_cand" -c 'echo $((BASH_VERSINFO[0]*100+BASH_VERSINFO[1]))' 2>/dev/null || true)" + if [[ "$_v" =~ ^[0-9]+$ ]] && ((_v >= 404)); then + exec env _AI_BASH_REEXEC=1 "$_cand" "${BASH_SOURCE[0]}" "$@" + fi + done + fi + echo "agent-kit: requires Bash >= 4.4 (running ${BASH_VERSION:-unknown})." >&2 + echo " Install a newer Bash (e.g. 'brew install bash') or set TOOL_BASH=/path/to/bash." >&2 + exit 127 +fi + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SELF_DIR/.." && pwd)" +LIBEXEC_DIR="$ROOT_DIR/libexec" + +print_list() { + echo "Available commands (run 'agent-kit --help' for details):" + echo + if [[ -x "$LIBEXEC_DIR/sh-introspect" ]]; then + "${BASH:-bash}" "$LIBEXEC_DIR/sh-introspect" --list "$LIBEXEC_DIR" + else + find "$LIBEXEC_DIR" -maxdepth 1 -type f | sort | while IFS= read -r f; do + printf ' %s\n' "$(basename "$f")" + done + fi +} + +case "${1:-}" in + ""|--list|list) + print_list + [[ "${1:-}" == "" ]] && exit 2 + exit 0 + ;; + -h|--help) + sed -n '2,25p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + echo + print_list + exit 0 + ;; + -V|--version) + if [[ -r "$ROOT_DIR/VERSION" ]]; then + printf 'agent-kit %s\n' "$(tr -d '[:space:]' < "$ROOT_DIR/VERSION")" + else + printf 'agent-kit (version unknown)\n' + fi + exit 0 + ;; +esac + +cmd="$1" +shift + +# A command is a single libexec token, never a path. Reject anything with a +# slash or a leading dot so `agent-kit ../../etc/x` can never escape libexec/ and exec +# an arbitrary file. +if [[ ! "$cmd" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "agent-kit: invalid command name '$cmd' (see 'agent-kit --list')" >&2 + exit 2 +fi + +# Resolve the command file: exact name first, then the `ai-` prefixed form. +target="" +if [[ -f "$LIBEXEC_DIR/$cmd" ]]; then + target="$LIBEXEC_DIR/$cmd" +elif [[ -f "$LIBEXEC_DIR/ai-$cmd" ]]; then + target="$LIBEXEC_DIR/ai-$cmd" +else + echo "agent-kit: unknown command '$cmd' (see 'agent-kit --list')" >&2 + exit 2 +fi + +# Run the subcommand with the SAME bash that is running this dispatcher. The +# installer wrappers (and the Homebrew formula) invoke bin/agent-kit under a Bash >= 4.4, +# so "$BASH" propagates that interpreter to subcommands — important on macOS, +# whose default /bin/bash is 3.2 and cannot run the toolkit's modules. +exec "${BASH:-bash}" "$target" "$@" diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md new file mode 100644 index 0000000..0d92cab --- /dev/null +++ b/docs/AI_USAGE.md @@ -0,0 +1,29 @@ +# Using AgentKit with coding agents + +## Operating contract + +Give the agent this instruction: + +> Use the repository's `agent-kit` CLI as the preferred interface for repository search, context collection, editing, rollback, test selection, and verification. Start with `agent-kit --help` and `agent-kit --help`. Respect scopes and guardrails, prefer structured output where available, and run `agent-kit verify` before claiming completion. + +## Recommended sequence + +1. **Discover:** use `agent-kit search` or `agent-kit search-multi` instead of recursively loading the repository. +2. **Bound context:** use `pack-context`, `agent-kit diff-context`, or Repomix helpers only for relevant files. +3. **Plan:** define allowed paths, blocked paths, deletion policy, and verification steps. +4. **Change:** use guarded editing and preserve snapshots. +5. **Test:** use `agent-kit test-select` or focused tests before the full suite. +6. **Verify:** run `agent-kit verify` and retain exact evidence. +7. **Recover:** use `agent-kit rollback` when a guarded edit must be reverted. + +## Human review + +Agents must not receive unrestricted host permissions merely because these tools have safety checks. Keep runtime permissions minimal, inspect diffs, review executed commands, and require passing verification before merge. + +## Runtime integration + +- **GitHub Copilot:** repository instructions are supplied through `.github/copilot-instructions.md` and `AGENTS.md`. +- **Claude Code:** `CLAUDE.md` points to the canonical `AGENTS.md` contract. +- **OpenCode and compatible agents:** use the root `AGENTS.md` and the assets under `integrations/`. + +Keep `AGENTS.md` canonical. Runtime-specific files should only bridge to it or add unavoidable runtime details. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md new file mode 100644 index 0000000..96abd5c --- /dev/null +++ b/docs/COMMANDS.md @@ -0,0 +1,31 @@ +# Command map + +The exact supported options and output schema are authoritative in each command's `--help` output. + +Commands are shown as `agent-kit `. If you set the optional alias +`alias akit='agent-kit'`, the short form `akit ` works everywhere +(see [EXAMPLES.md](EXAMPLES.md)). + +| Command | Purpose | +|---|---| +| `agent-kit search` | Scoped repository search across available backends. | +| `agent-kit search-multi` | Run multiple bounded searches. | +| `agent-kit search-introspect` | Explain search capability and routing. | +| `agent-kit diff-context` | Build context around current changes. | +| `agent-kit structured` | Produce machine-readable command output. | +| `agent-kit task` | Run a bounded repository task workflow. | +| `agent-kit edit` | Apply guarded, reviewable edits. | +| `agent-kit rollback` | Restore a prior guarded-edit state. | +| `agent-kit test-select` | Select tests relevant to changed files. | +| `agent-kit verify` | Run repository-aware verification. | +| `agent-kit doc-check` | Check documentation references and freshness. | +| `pack-context` | Package selected context for an agent. | +| `preview-file` | Safely preview bounded file content. | +| `git-forensics` | Inspect repository history and change provenance. | +| `gh-pr-context` | Collect focused pull-request context. | +| `repomix-context-tree` | Build a structured Repomix context tree. | +| `repomix-scc-router` | Route context packing using repository size and language data. | +| `query-usage` | Inspect recorded tool usage. | +| `session-checkpoint` | Record a session checkpoint. | + +Use `agent-kit --help`, `agent-kit --help`, or the executable's direct `--help` output before automation. Do not infer unsupported flags from this overview. diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md new file mode 100644 index 0000000..95a26c0 --- /dev/null +++ b/docs/EXAMPLES.md @@ -0,0 +1,355 @@ +# Examples + +One runnable example per command, generated from each command's own `# Example:` +block. + +These snippets use the short **`akit`** alias. Enable it once (add to your shell +rc), then every example below works verbatim: + +```bash +alias akit='agent-kit' +``` + +The canonical command is `agent-kit` — if you have not set the alias, replace +`akit` with `agent-kit`. The authoritative contract for any command is always +`agent-kit --help` (and `--introspect` for JSON). + +> Regenerate this file with: `bash scripts/gen-examples.sh > docs/EXAMPLES.md` + +### `akit diff-context` +Pack changed or targeted files into AI context bundles. +(thin loader — implementation under scripts/ai/internal/ai-diff-context/) + +```bash +akit diff-context unstaged --dry-run # preview which changed files would be packed +akit diff-context since HEAD~1 --dry-run # preview a bundle for the last commit's changes +``` + +### `akit doc-check` +Verify documentation quality (lint, links, drift) for AI agents. + +```bash +akit doc-check --help # see modes and env before running (safe) +akit doc-check links README.md # check links in one file (read-only) +``` + +### `akit edit` +Guarded edit wrapper for broad repository modifications (thin loader). + +```bash +akit edit --help # see every mode and flag, safely +akit edit sd OldName NewName . --dry-run # preview a rename, changing nothing +``` + +### `akit file-freshness` +Show which docs/config files have uncommitted changes (git status of key paths). + +```bash +akit file-freshness # list uncommitted changes under docs/, .github/, AGENTS.md +``` + +### `akit rollback` +Review and apply repository-local rollback snapshots created by AI tooling sessions. + +```bash +akit rollback list # list restore points (read-only, safe) +akit rollback show SNAPSHOT_ID # preview one snapshot's files (id from `list`) +``` + +### `akit search` +ai-search.sh — unified repository search entrypoint (thin facade). + +```bash +akit search text "TODO" . # find every TODO across the tree +akit search todo . # list curated TODO/FIXME/HACK/XXX markers +akit search files config . # find files whose name contains "config" +akit search-introspect # full mode/flag/env capability map +akit search doctor # check which search backends are available +``` + +### `akit search-introspect` +ai-search-introspect.sh — print 100% of the modes, flags, env vars, and +per-mode argument contracts that ai-search.sh and ai-search-multi.sh accept. + +```bash +akit search-introspect # print the full ai-search capability map +akit search-introspect --probe # confirm every search mode is reachable +``` + +### `akit search-multi` +Batch wrapper around ai-search.sh: run one safe search MODE against several +queries in a single approved invocation. + +```bash +akit search-multi text foo bar . # search two terms in one pass +akit search-multi files niri vicinae . # find files matching either name +akit search-multi changed-files . # list files changed but not staged +``` + +### `akit structured` +Structured data query wrapper for AI agents. + +```bash +akit structured json package.json '.scripts' # print the "scripts" section of package.json with jq +akit structured validate-json composer.json # check that composer.json is valid JSON +akit structured csv data.csv --head 20 # preview the first 20 rows of a CSV file +``` + +### `akit task` +Project task discovery wrapper for AI agents. + +```bash +akit task list # list every task this project already defines +akit task test # print the command to run this repo's tests +akit task verify # print the recommended "verify" command to run +``` + +### `akit test-select` +Select focused tests for AI-driven changes (lists tests; never runs them). + +```bash +akit test-select changed # list tests for your current changes (read-only) +akit test-select json | jq . # feed the selection to another tool +``` + +### `akit verify` +Project-aware verification gate for AI-driven changes (thin loader). + +```bash +akit verify --help # see accepted args before running (safe) +akit verify . # verify the change in the current project +``` + +### `akit verify-html` +Verify only the HTML files in a change (thin wrapper over `agent-kit verify`). + +```bash +akit verify-html --help # see what the HTML verify wrapper does, safely +akit verify-html . # verify the HTML files in the current project +``` + +### `akit verify-js` +Verify only the JavaScript files in a change (thin wrapper over `agent-kit verify`). + +```bash +akit verify-js --help # see what the JS verify wrapper does, safely +akit verify-js . # verify the JavaScript files in the current project +``` + +### `akit verify-php` +Verify only the PHP files in a change (thin wrapper over `agent-kit verify`). + +```bash +akit verify-php --help # see what the PHP verify wrapper does, safely +akit verify-php . # verify the PHP files in the current project +``` + +### `akit verify-ts` +Verify only the TypeScript files in a change (thin wrapper over `agent-kit verify`). + +```bash +akit verify-ts --help # see what the TS verify wrapper does, safely +akit verify-ts . # verify the TypeScript files in the current project +``` + +### `akit verify-vue` +Verify only the Vue files in a change (thin wrapper over `agent-kit verify`). + +```bash +akit verify-vue --help # see what the Vue verify wrapper does, safely +akit verify-vue . # verify the Vue files in the current project +``` + +### `akit all-f-into-one` +all-f-into-one.sh (formerly all_in_one.sh / combine_files.sh) +Recursively collects filenames and contents, writes them to a single output file at project root. +Prunes ignored directories (entire subtrees) and excludes selected files. +Each file block (header + content + footer) is wrapped inside triple backticks. + +```bash +akit all-f-into-one --help # see what this does without combining anything +akit all-f-into-one --introspect # print the machine-readable JSON contract +``` + +### `akit check-file-refs` +Find tracked files that are not referenced anywhere else in the repository. +Read-only: surfaces orphaned docs and unused assets. No mutation. + +```bash +akit check-file-refs . # list tracked files nothing else references +akit check-file-refs docs --ext md # find orphaned markdown docs under docs/ +``` + +### `akit fd-files` +Repo-aware file discovery wrapper. + +```bash +akit fd-files README . # find files whose name contains "README" +akit fd-files config docs --type md # find markdown files under docs/ matching "config" +``` + +### `akit gh-pr-context` +Full PR context wrapper for review and context packing. + +```bash +akit gh-pr-context 123 # show PR #123 metadata, files, and description +akit gh-pr-context 123 --checks --reviews # add CI check status and review summaries +akit gh-pr-context 123 --json # emit the full PR context as JSON for tools +``` + +### `akit git-branch-origin` +Detect the branch the current branch was most likely created from ("branched off"). + +```bash +akit git-branch-origin # print the branch your branch was created from +akit git-branch-origin --field all # show name, merge-base sha, and commit distance +akit git-branch-origin --json # same detection as a JSON envelope for tools +``` + +### `akit git-forensics` +Repo-aware git history and blame wrapper. + +```bash +akit git-forensics S "TODO" README.md # find commits that added/removed "TODO" in README.md +akit git-forensics G "function foo" README.md # search history by regex in one file +akit git-forensics blame 1,20 README.md # annotate who last changed lines 1-20 +``` + +### `akit pack-context` +Safe context packer wrapper. + +```bash +akit pack-context auto --include "docs/**/*.md" # bundle the docs into one AI context file +``` + +### `akit preview-file` +preview-file.sh — safely preview a slice of a text file with guardrails +(size/byte gate, binary + .git blocking, column truncation). + +```bash +akit preview-file README.md # show the first 200 lines, safely +akit preview-file README.md --range 1:40 # show only lines 1-40 of the file +akit preview-file README.md --dry-run # check a file is previewable (no content) +``` + +### `akit query-usage` +Estimate the context/token cost of a file or directory (read-only budgeting). + +```bash +akit query-usage libexec # estimate the token cost of the libexec/ directory +akit query-usage README.md # estimate the token cost of a single file +akit query-usage . --multiplier 2 # weight the whole-repo estimate by 2x +``` + +### `akit repomix-context-tree` +Plan and pack a repository into ranked Repomix context bundles, grouped by folder tree. + +```bash +akit repomix-context-tree analyze . # analyze the repo and write a bundle plan without packing anything +akit repomix-context-tree all . # analyze, then pack every route the plan marks for packing +``` + +### `akit repomix-ensure-fresh` +Ensure the Repomix context bundle is fresh before an agent relies on it. + +```bash +akit repomix-ensure-fresh . # check bundle freshness and only report (never regenerates) +akit repomix-ensure-fresh . --regen # check, and regenerate the bundle if it is stale, expired, or missing +``` + +### `akit repomix-freshness` +Check freshness of the generated Repomix context bundle. + +```bash +akit repomix-freshness . # report how old the generated context bundle is +AI_OUTPUT=json akit repomix-freshness . # same freshness check as machine-readable JSON +``` + +### `akit repomix-scc-router` +Rank a repository's folders by scc code metrics and pack them into Repomix bundles. + +```bash +akit repomix-scc-router stats . # run scc analysis and write per-file and per-folder code metrics +akit repomix-scc-router all . # run stats, build a ranked bundle plan, then pack the bundles +``` + +### `akit repo-stats` +Count the files Git currently tracks in this repository. + +```bash +akit repo-stats # print how many files Git currently tracks in this repository +``` + +### `akit repo-tool-inventory` +List every toolkit command with its one-line summary (a discoverable map). + +```bash +akit repo-tool-inventory # see every command and what it does +akit repo-tool-inventory --json | jq . # feed the catalog to an agent +``` + +### `akit rg-code` +Production-grade code search wrapper with repo-aware defaults. + +```bash +akit rg-code "TODO" . # find every TODO under the current directory +akit rg-code "function" src --files # list files under src/ that contain "function" +akit rg-code "config" . --mode php # search only PHP files for "config" +``` + +### `akit run-repomix-context` +Generate repository context tree through the safer shared wrapper path. + +```bash +akit run-repomix-context . # pack the whole current repo into an LLM-ready context bundle +akit run-repomix-context . --depth 2 --top 0 # same, but tune folder depth and pack all ranked routes +``` + +### `akit run-repomix-file` +Exact single-file Repomix wrapper. + +```bash +akit run-repomix-file . README.md # pack a single file into a compressed context bundle +akit run-repomix-file . src/app.js --style json --no-compress # pack one file as uncompressed JSON output +``` + +### `akit run-repo-tests` +Run the repository's existing test suites with parallel-first defaults. + +```bash +akit run-repo-tests --help # see options and defaults before running (safe) +PARATEST_PROCS=8 akit run-repo-tests # run the full suite with 8 parallel workers +``` + +### `akit run-test-focused` +Run a FOCUSED PHPUnit selection (a --filter pattern or a single test file). + +```bash +akit run-test-focused --help # see accepted forms before running (safe) +akit run-test-focused --filter MyThingTest # run only tests matching MyThingTest +``` + +### `akit session-checkpoint` +Create a repository-local checkpoint using the shared snapshot system. + +```bash +akit session-checkpoint # save a snapshot into .ai-logs/snapshots/ +akit session-checkpoint before-refactor # save a labelled snapshot you can find later +``` + +### `akit sh-introspect` +Universal shell-script introspector (static, pure-Bash parser). + +```bash +sh-introspect libexec/ai-search # see what ai-search accepts, safely +sh-introspect --format=json libexec/ai-edit | jq . # machine-readable contract +sh-introspect --list libexec # a discoverable map of every command +``` + +### `akit watch-loop` +Re-run a command automatically whenever watched files change (blocks until Ctrl-C). + +```bash +akit watch-loop "akit verify" # re-run verify whenever files change (Ctrl-C to stop) +akit watch-loop "akit task test" sh,md # re-run tests only when .sh or .md files change +``` diff --git a/docs/SECURITY_MODEL.md b/docs/SECURITY_MODEL.md new file mode 100644 index 0000000..6dc7228 --- /dev/null +++ b/docs/SECURITY_MODEL.md @@ -0,0 +1,33 @@ +# Security model + +## Goals + +- Reduce accidental out-of-scope repository reads and writes. +- Make command execution, edits, tests, and verification observable. +- Preserve rollback paths and original repository state. +- Prevent secrets and local session evidence from entering releases. + +## Non-goals + +- Operating-system sandboxing. +- Protection from a fully compromised user account or runner. +- Automatic trust of repository instructions or third-party tools. +- Proof that AI-generated changes are correct or secure. + +## Trust boundaries + +Repository files, branch names, commit messages, issue text, pull-request content, generated context, and agent output are untrusted input. They must not be interpolated into executable shell strings. External tools and GitHub Actions are dependencies that require version control and review. + +## Required controls + +- Least-privilege agent permissions. +- Explicit allowed and blocked paths. +- Guarded execution with timeouts and process-tree cleanup. +- Snapshot or rollback capability before mutation. +- Secret redaction and generated-log exclusion. +- Verification evidence before completion. +- Human review before merge or release. + +## Release boundary + +Release archives must contain only intended source, documentation, integrations, hooks, and configuration. They must exclude `.git`, `.ai-logs`, local caches, temporary files, context packs, test output, and environment files. diff --git a/hooks/agent/session-checkpoint b/hooks/agent/session-checkpoint new file mode 100755 index 0000000..5c6b37d --- /dev/null +++ b/hooks/agent/session-checkpoint @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# hooks/agent/session-checkpoint — thin hook-slot shim delegating to +# libexec/session-checkpoint. Kept as a separate path so agent runtimes that +# discover hooks under hooks/agent/ (rather than libexec/) can find this +# command without duplicating its implementation. +set -euo pipefail +exec bash "$(cd "$(dirname "${BASH_SOURCE[0]}")/../../libexec" && pwd)/session-checkpoint" "$@" diff --git a/hooks/agent/watch-loop b/hooks/agent/watch-loop new file mode 100755 index 0000000..bf30949 --- /dev/null +++ b/hooks/agent/watch-loop @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# hooks/agent/watch-loop — thin hook-slot shim delegating to +# libexec/watch-loop. Kept as a separate path so agent runtimes that +# discover hooks under hooks/agent/ (rather than libexec/) can find this +# command without duplicating its implementation. +set -euo pipefail +exec bash "$(cd "$(dirname "${BASH_SOURCE[0]}")/../../libexec" && pwd)/watch-loop" "$@" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..1f63b73 --- /dev/null +++ b/install.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: ./install.sh [--prefix PATH] [--bindir PATH] + +Defaults: + --prefix ${XDG_DATA_HOME:-$HOME/.local/share}/agent-kit + --bindir $HOME/.local/bin +EOF +} + +source_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +prefix=${XDG_DATA_HOME:-$HOME/.local/share}/agent-kit +bindir=$HOME/.local/bin + +while (($# > 0)); do + case "$1" in + --prefix) + (($# >= 2)) || { printf 'error: --prefix requires a path\n' >&2; exit 2; } + prefix=$2 + shift 2 + ;; + --bindir) + (($# >= 2)) || { printf 'error: --bindir requires a path\n' >&2; exit 2; } + bindir=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'error: unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +for command in bash git rg jq; do + command -v "$command" >/dev/null 2>&1 || { + printf 'error: required command not found: %s\n' "$command" >&2 + exit 1 + } +done + +if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4))); then + printf 'error: Bash 4.4 or newer is required\n' >&2 + exit 1 +fi + +for required in bin/agent-kit lib libexec share; do + [[ -e "$source_root/$required" ]] || { + printf 'error: installer source is incomplete: missing %s\n' "$required" >&2 + exit 1 + } +done + +prefix_parent=$(dirname -- "$prefix") +mkdir -p -- "$prefix_parent" "$bindir" +stage=$(mktemp -d "$prefix_parent/.agent-kit.install.XXXXXX") +backup='' +installed=0 +wrapper_tmp='' +cleanup() { + local rc=$? + if ((rc != 0)) && ((installed == 1)); then + rm -rf -- "$prefix" + if [[ -n "$backup" && -e "$backup" ]]; then + mv -- "$backup" "$prefix" + fi + fi + rm -rf -- "$stage" + [[ -n "$wrapper_tmp" ]] && rm -f -- "$wrapper_tmp" +} +trap cleanup EXIT + +copy_paths=(bin lib libexec share hooks integrations docs README.md INSTALL.md AGENTS.md CLAUDE.md LICENSE NOTICE SECURITY.md SUPPORT.md CONTRIBUTING.md CHANGELOG.md VERSION uninstall.sh) +for path in "${copy_paths[@]}"; do + [[ -e "$source_root/$path" ]] || continue + cp -R -- "$source_root/$path" "$stage/" +done +printf '%s\n' 'agent-kit' > "$stage/.agent-kit-install" + +# Refuse to clobber a foreign command already installed as `$bindir/agent-kit` (the +# name is generic). Only overwrite our own wrapper unless AGENTKIT_FORCE=1. Do this +# before replacing the prefix so a rejected wrapper leaves the existing install intact. +wrapper_marker='# agent-kit-wrapper' +if [[ -e "$bindir/agent-kit" && "${AGENTKIT_FORCE:-0}" != "1" ]]; then + if ! grep -Fq -- "$wrapper_marker" "$bindir/agent-kit" 2>/dev/null; then + printf 'error: %s already exists and is not an agent-kit wrapper.\n' "$bindir/agent-kit" >&2 + printf ' Remove it, choose another --bindir, or re-run with AGENTKIT_FORCE=1 to overwrite.\n' >&2 + exit 1 + fi +fi + +if [[ -e "$prefix" ]]; then + backup="${prefix}.backup.$(date +%Y%m%d%H%M%S).$$" + mv -- "$prefix" "$backup" +fi + +if ! mv -- "$stage" "$prefix"; then + [[ -n "$backup" && -e "$backup" ]] && mv -- "$backup" "$prefix" + printf 'error: installation failed; previous installation restored when available\n' >&2 + exit 1 +fi +installed=1 + +wrapper_tmp=$(mktemp "$bindir/.agent-kit.XXXXXX") +# Pin the wrapper to the Bash that ran this installer. We already verified it is +# >= 4.4 above, so the installed `agent-kit` always launches the dispatcher under a +# capable interpreter (bin/agent-kit then propagates it to subcommands via "$BASH"). +# This avoids macOS silently running everything under its 3.2 /bin/bash. +cat > "$wrapper_tmp" < 0)); do + arg="$1" + shift_by=0 + if parse_common_option "$arg" "${2:-}"; then + shift_by="$COMMON_OPTION_CONSUMED" + shift "$shift_by" + else + positional+=("$arg") + shift + fi + done + + local ref="${positional[0]:-}" + [[ -n "$ref" ]] || die "git ref required" + + section "Changed files since $ref" + + local files=() + local diff_artifact="" + mapfile -t files < <((git diff --name-only "$ref"...HEAD 2>/dev/null || git diff --name-only "$ref") | filter_existing) + + append_tests files + + diff_artifact="$(write_diff_artifact "since-${ref//\//-}" since "$ref" || true)" + [[ -n "$diff_artifact" ]] && files+=("$diff_artifact") + + mapfile -t files < <(deduplicate_files "${files[@]+${files[@]}}" | filter_existing) + pack_files_list "since-${ref//\//-}" "${files[@]}" +} + +cmd_unstaged() { + local shift_by=0 + + while (($# > 0)); do + shift_by=0 + if parse_common_option "$1" "${2:-}"; then + shift_by="$COMMON_OPTION_CONSUMED" + shift "$shift_by" + else + die "unknown option: $1" + fi + done + + section "Unstaged, staged, and untracked changed files" + + local files=() + local diff_artifact="" + mapfile -t files < <({ + git diff --name-only + git diff --cached --name-only + git ls-files --others --exclude-standard + } | sort -u | filter_existing) + + append_tests files + + diff_artifact="$(write_diff_artifact "unstaged" unstaged || true)" + [[ -n "$diff_artifact" ]] && files+=("$diff_artifact") + + mapfile -t files < <(deduplicate_files "${files[@]+${files[@]}}" | filter_existing) + pack_files_list "unstaged" "${files[@]}" +} + +cmd_pr() { + local positional=() + local shift_by=0 + local arg + + while (($# > 0)); do + arg="$1" + shift_by=0 + if parse_common_option "$arg" "${2:-}"; then + shift_by="$COMMON_OPTION_CONSUMED" + shift "$shift_by" + else + positional+=("$arg") + shift + fi + done + + local pr="${positional[0]:-}" + [[ -n "$pr" ]] || die "PR number required" + + require_bins gh + section "Files in PR #$pr" + + local files=() + local diff_artifact="" + mapfile -t files < <(gh pr view "$pr" --json files --jq '.files[].path' | filter_existing) + + append_tests files + + diff_artifact="$(write_diff_artifact "pr-${pr}" pr "$pr" || true)" + [[ -n "$diff_artifact" ]] && files+=("$diff_artifact") + + mapfile -t files < <(deduplicate_files "${files[@]+${files[@]}}" | filter_existing) + pack_files_list "pr-${pr}" "${files[@]}" +} + +cmd_recent() { + local count=10 + local shift_by=0 + + while (($# > 0)); do + case "$1" in + --count | -n) + count="${2:?count required}" + shift 2 + ;; + --count=*) + count="${1#*=}" + shift + ;; + *) + shift_by=0 + if parse_common_option "$1" "${2:-}"; then + shift_by="$COMMON_OPTION_CONSUMED" + shift "$shift_by" + else + die "unknown option: $1" + fi + ;; + esac + done + + section "Files changed in last $count commits" + + local files=() + local diff_artifact="" + mapfile -t files < <(git log --name-only --pretty=format: -"$count" | sort -u | grep -v '^$' | filter_existing) + + append_tests files + + diff_artifact="$(write_diff_artifact "recent-${count}" recent "$count" || true)" + [[ -n "$diff_artifact" ]] && files+=("$diff_artifact") + + mapfile -t files < <(deduplicate_files "${files[@]+${files[@]}}" | filter_existing) + pack_files_list "recent-${count}" "${files[@]}" +} + +cmd_touched() { + local positional=() + local shift_by=0 + local arg + + while (($# > 0)); do + arg="$1" + shift_by=0 + if parse_common_option "$arg" "${2:-}"; then + shift_by="$COMMON_OPTION_CONSUMED" + shift "$shift_by" + else + positional+=("$arg") + shift + fi + done + + local pattern="${positional[0]:-}" + [[ -n "$pattern" ]] || die "pattern required" + + require_bins fd rg + section "Files matching: $pattern" + + local root + local files=() + local diff_artifact="" + + root="$(git_root)" + mapfile -t files < <({ + fd --hidden -E vendor -E node_modules -E dist -E .git "$pattern" "$root" + rg -l --hidden -g '!vendor' -g '!node_modules' -g '!dist' -g '!.git' "$pattern" "$root" 2>/dev/null || true + } | sort -u | filter_existing) + + append_tests files + + diff_artifact="$(write_diff_artifact "touched-${pattern//[^a-zA-Z0-9]/-}" touched "${files[@]}" || true)" + [[ -n "$diff_artifact" ]] && files+=("$diff_artifact") + + mapfile -t files < <(deduplicate_files "${files[@]+${files[@]}}" | filter_existing) + pack_files_list "touched-${pattern//[^a-zA-Z0-9]/-}" "${files[@]}" +} diff --git a/lib/ai-diff-context/helpers.sh b/lib/ai-diff-context/helpers.sh new file mode 100644 index 0000000..f180de9 --- /dev/null +++ b/lib/ai-diff-context/helpers.sh @@ -0,0 +1,395 @@ +# shellcheck shell=bash +# shellcheck disable=SC2154,SC2034 # cross-module globals via dynamic scope +# shellcheck disable=SC2164 # cd calls are inside ( ) subshells under set -e (output captured); a failed cd aborts only the subshell — behavior preserved verbatim from the monolith +# ai-diff-context/10-helpers.sh — option parsing, file collection, packing helpers. +# +# Sourced by scripts/ai/ai-diff-context.sh (thin loader). Not an entrypoint. +# Behavior is byte-for-byte identical to the previous monolithic version. + +parse_common_option() { + COMMON_OPTION_CONSUMED=0 + + case "${1:-}" in + --include-diffs) + INCLUDE_DIFFS=1 + COMMON_OPTION_CONSUMED=1 + return 0 + ;; + --no-tests) + INCLUDE_TESTS=0 + COMMON_OPTION_CONSUMED=1 + return 0 + ;; + --no-secrets-scan) + SECRETS_SCAN=0 + COMMON_OPTION_CONSUMED=1 + return 0 + ;; + --dry-run) + DRY_RUN=1 + COMMON_OPTION_CONSUMED=1 + return 0 + ;; + --strict) + STRICT_TOKENS=1 + COMMON_OPTION_CONSUMED=1 + return 0 + ;; + --token-budget) + TOKEN_BUDGET="${2:?token budget required}" + COMMON_OPTION_CONSUMED=2 + return 0 + ;; + --token-budget=*) + TOKEN_BUDGET="${1#*=}" + COMMON_OPTION_CONSUMED=1 + return 0 + ;; + --split) + SPLIT_OUTPUT="${2:?split size required}" + COMMON_OPTION_CONSUMED=2 + return 0 + ;; + --split=*) + SPLIT_OUTPUT="${1#*=}" + COMMON_OPTION_CONSUMED=1 + return 0 + ;; + --help | -h) + usage + exit 0 + ;; + esac + + return 1 +} + +repo_relative_file() { + local input="$1" + local root + root="$(git_root)" + + input="${input#./}" + + if [[ "$input" == "$root/"* ]]; then + input="${input#"$root/"}" + fi + + printf '%s\n' "$input" +} + +filter_existing() { + local root + root="$(git_root)" + + while IFS= read -r f; do + [[ -n "$f" ]] || continue + f="$(repo_relative_file "$f")" + + if [[ -f "$root/$f" ]]; then + printf '%s\n' "$f" + fi + done +} + +deduplicate_files() { + printf '%s\n' "$@" | sed '/^$/d' | sort -u +} + +regex_escape_lines() { + sed -E 's/[][(){}.^$+*?|\\]/\\&/g' +} + +build_stem_regex() { + local stems=("$@") + + printf '%s\n' "${stems[@]}" | + sed '/^$/d' | + sort -u | + regex_escape_lines | + paste -sd'|' - +} + +collect_file_stems() { + local files=("$@") + local f base stem + + for f in "${files[@]}"; do + base="$(basename "$f")" + stem="${base%.*}" + + [[ -n "$stem" ]] || continue + [[ "$stem" != "$base" || "$base" != "." ]] || continue + + printf '%s\n' "$stem" + done | sort -u +} + +collect_related_tests() { + local files=("$@") + local root + local stems=() + local stem_regex + + root="$(git_root)" + + if [[ "$INCLUDE_TESTS" != "1" ]]; then + return 0 + fi + + if ((${#files[@]} == 0)); then + return 0 + fi + + if ! command -v fd >/dev/null 2>&1; then + log_warn "fd not installed; skipping related test discovery" + return 0 + fi + + mapfile -t stems < <(collect_file_stems "${files[@]}") + ((${#stems[@]} > 0)) || return 0 + + stem_regex="$(build_stem_regex "${stems[@]}")" + [[ -n "$stem_regex" ]] || return 0 + + { + # Common direct naming conventions. + fd --hidden -E vendor -E node_modules -E dist -E .git \ + "(${stem_regex})(Test)?\.php$" "$root" 2>/dev/null || true + + fd --hidden -E vendor -E node_modules -E dist -E .git \ + "(${stem_regex})\.(test|spec)\.(js|ts|jsx|tsx|mjs|cjs)$" "$root" 2>/dev/null || true + + fd --hidden -E vendor -E node_modules -E dist -E .git \ + "(${stem_regex})Test\.(kt|kts)$" "$root" 2>/dev/null || true + + # Conventional test folders where names may not match exactly. + if command -v rg >/dev/null 2>&1; then + rg -l --hidden \ + -g '!vendor' \ + -g '!node_modules' \ + -g '!dist' \ + -g '!.git' \ + -g 'tests/**' \ + -g 'test/**' \ + -g 'spec/**' \ + -g '__tests__/**' \ + -g '*.{php,js,ts,jsx,tsx,kt,kts}' \ + "(${stem_regex})" "$root" 2>/dev/null || true + fi + } | filter_existing | sort -u +} + +estimate_files_tokens() { + local root + local total_bytes=0 + local f + local bytes + + root="$(git_root)" + + for f in "$@"; do + [[ -f "$root/$f" ]] || continue + bytes="$(wc -c <"$root/$f" | tr -d ' ')" + total_bytes=$((total_bytes + bytes)) + done + + echo $(((total_bytes + 3) / 4)) +} + +write_diff_artifact() { + local label="$1" + shift + local mode="$1" + shift || true + + [[ "$INCLUDE_DIFFS" == "1" ]] || return 0 + + local root + local diff_file + root="$(git_root)" + diff_file="${SESSION_DIR}/${label}.diff" + + mkdir -p "$SESSION_DIR" + + case "$mode" in + since) + local ref="${1:?ref required}" + ( + cd "$root" + git diff "$ref"...HEAD 2>/dev/null || git diff "$ref" + ) >"$diff_file" || true + ;; + unstaged) + ( + cd "$root" + printf '# git diff\n\n' + git diff || true + printf '\n# git diff --cached\n\n' + git diff --cached || true + printf '\n# untracked files\n\n' + git ls-files --others --exclude-standard | sed 's/^/UNTRACKED: /' || true + ) >"$diff_file" + ;; + pr) + local pr="${1:?PR number required}" + require_bins gh + gh pr diff "$pr" >"$diff_file" 2>/dev/null || true + ;; + recent) + local count="${1:?count required}" + ( + cd "$root" + git diff "HEAD~${count}"..HEAD 2>/dev/null || git diff HEAD + ) >"$diff_file" || true + ;; + touched) + ( + cd "$root" + git diff -- "$@" 2>/dev/null || true + ) >"$diff_file" || true + ;; + *) + die "unknown diff artifact mode: $mode" + ;; + esac + + if [[ -s "$diff_file" ]]; then + repo_relative_file "$diff_file" + else + rm -f "$diff_file" + fi +} + +pack_files_list() { + local label="$1" + shift + local files=("$@") + local root + local out_file + local manifest + local list_file + local tokens + local estimated_input_tokens + local repomix_args=() + + root="$(git_root)" + + mapfile -t files < <(deduplicate_files "${files[@]}" | filter_existing) + ((${#files[@]} > 0)) || die "no files to pack" + + mkdir -p "$OUTPUT_DIR" + out_file="${OUTPUT_DIR}/${label}-$(date +%Y%m%d-%H%M%S).xml" + manifest="${out_file%.xml}.manifest.json" + + estimated_input_tokens="$(estimate_files_tokens "${files[@]}")" + + if [[ "$DRY_RUN" == "1" ]]; then + jq -n \ + --arg label "$label" \ + --arg output "$out_file" \ + --argjson files "$(printf '%s\n' "${files[@]}" | jq -R . | jq -s .)" \ + --argjson estimated_tokens "$estimated_input_tokens" \ + --argjson token_budget "$TOKEN_BUDGET" \ + '{ + dry_run: true, + label: $label, + output: $output, + file_count: ($files | length), + estimated_input_tokens: $estimated_tokens, + token_budget: $token_budget, + files: $files + }' + return 0 + fi + + list_file="$(mktemp)" + printf '%s\n' "${files[@]}" >"$list_file" + + log_info "Packing ${#files[@]} files into context" + log_info "Estimated input tokens before packing: ~${estimated_input_tokens}" + + if [[ "$SECRETS_SCAN" == "1" ]]; then + section "Secrets scan" + require_clean_secret_scan "$root" + log_ok "No secrets found" + else + log_warn "Secrets scan disabled" + fi + + if command -v repomix >/dev/null 2>&1; then + repomix_args=(--stdin --output "$out_file" --style xml --compress) + + if [[ -n "$SPLIT_OUTPUT" ]]; then + repomix_args+=(--split-output "$SPLIT_OUTPUT") + fi + + ( + cd "$root" + repomix "${repomix_args[@]}" <"$list_file" + ) + elif command -v files-to-prompt >/dev/null 2>&1; then + mapfile -t file_args <"$list_file" + ( + cd "$root" + files-to-prompt "${file_args[@]}" + ) >"$out_file" + else + rm -f "$list_file" + die "no context packer available; install repomix or files-to-prompt" + fi + + rm -f "$list_file" + + tokens="$(estimate_tokens "$out_file")" + + if ! within_token_budget "$out_file" "$TOKEN_BUDGET"; then + if [[ "$STRICT_TOKENS" == "1" ]]; then + die "context is ~${tokens} tokens, exceeding strict budget ${TOKEN_BUDGET}" + fi + + log_warn "Context is ~${tokens} tokens, exceeding budget ${TOKEN_BUDGET}" + else + log_ok "Context packed: ~${tokens} tokens" + fi + + jq -n \ + --arg label "$label" \ + --arg out "$out_file" \ + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --argjson files "$(printf '%s\n' "${files[@]}" | jq -R . | jq -s .)" \ + --argjson tokens "$tokens" \ + --argjson estimated_input_tokens "$estimated_input_tokens" \ + --argjson token_budget "$TOKEN_BUDGET" \ + --argjson include_tests "$INCLUDE_TESTS" \ + --argjson include_diffs "$INCLUDE_DIFFS" \ + --argjson strict_tokens "$STRICT_TOKENS" \ + --arg split_output "$SPLIT_OUTPUT" \ + '{ + label: $label, + output: $out, + ts: $ts, + file_count: ($files | length), + estimated_tokens: $tokens, + estimated_input_tokens: $estimated_input_tokens, + token_budget: $token_budget, + include_tests: ($include_tests == 1), + include_diffs: ($include_diffs == 1), + strict_tokens: ($strict_tokens == 1), + split_output: (if $split_output == "" then null else $split_output end), + files: $files + }' >"$manifest" + + log_json "context.pack" "$(cat "$manifest")" + printf '%s\n' "$out_file" +} + +append_tests() { + local -n files_ref=$1 + local tests=() + + if [[ "$INCLUDE_TESTS" == "1" ]]; then + mapfile -t tests < <(collect_related_tests "${files_ref[@]+${files_ref[@]}}") + files_ref+=("${tests[@]+${tests[@]}}") + fi +} diff --git a/lib/ai-diff-context/main.sh b/lib/ai-diff-context/main.sh new file mode 100644 index 0000000..575bf78 --- /dev/null +++ b/lib/ai-diff-context/main.sh @@ -0,0 +1,32 @@ +# shellcheck shell=bash +# shellcheck disable=SC2154,SC2034 # cross-module globals via dynamic scope +# ai-diff-context/90-main.sh — subcommand dispatch (ai_diff_context_main). +# +# Sourced by scripts/ai/ai-diff-context.sh (thin loader). Not an entrypoint. +# Runs inside a function so the original top-level dispatch/exit flow is +# preserved exactly. Behavior is byte-for-byte identical to the monolith. + +ai_diff_context_main() { + agent_session_init "ai-diff-context" + require_bins jq + + cmd="${1:-}" + [[ -n "$cmd" ]] || { + usage + exit 1 + } + shift || true + + case "$cmd" in + since) cmd_since "$@" ;; + unstaged) cmd_unstaged "$@" ;; + pr) cmd_pr "$@" ;; + recent) cmd_recent "$@" ;; + touched) cmd_touched "$@" ;; + --help | -h) usage ;; + *) + usage + die "unknown command: $cmd" + ;; + esac +} diff --git a/lib/ai-edit/helpers.sh b/lib/ai-edit/helpers.sh new file mode 100644 index 0000000..6b2457a --- /dev/null +++ b/lib/ai-edit/helpers.sh @@ -0,0 +1,244 @@ +# shellcheck shell=bash +# shellcheck disable=SC2154,SC2034 # cross-module globals set by ai_edit_main/parse_tail via dynamic scope +# ai-edit/10-helpers.sh — JSON/diff/session/status helpers. +# +# Sourced by scripts/ai/ai-edit.sh (thin loader). Not an entrypoint. Behavior is +# byte-for-byte identical to the previous monolithic ai-edit.sh. + +show_diff() { + git --no-pager diff --stat || true + git --no-pager diff --color=always | sed -n '1,240p' || true +} + +dirty_files_json() { + if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + printf '[]\n' + return 0 + fi + + { + git diff --name-only || true + git diff --cached --name-only || true + git ls-files --others --exclude-standard || true + } | sort -u | sed '/^$/d' | jq -R . | jq -s -c . +} + +is_json_output() { + [[ "${format:-text}" == "json" || "${AI_OUTPUT:-}" == "json" ]] +} + +add_warning() { + warnings_json="$(jq -c --arg v "$1" '. + [$v]' <<<"$warnings_json")" +} + +add_error() { + errors_json="$(jq -c --arg v "$1" '. + [$v]' <<<"$errors_json")" +} + +json_array_diff() { + jq -c -n --argjson before "$1" --argjson after "$2" '$after - $before' +} + +save_diff_artifacts() { + mkdir -p "$SESSION_DIR" + git --no-pager diff --stat >"$SESSION_DIR/diff.stat" || true + git --no-pager diff >"$SESSION_DIR/diff.patch" || true +} + +write_session_manifest() { + local status="$1" + local manifest_path="$SESSION_DIR/edit-session.json" + local after_json session_changed_json + + mkdir -p "$SESSION_DIR" + after_json="$(dirty_files_json)" + session_changed_json="$(json_array_diff "$baseline_dirty_json" "$after_json")" + + # shellcheck disable=SC2086 # $manifest_path is injected as a JSON string literal into the jq program; path is repo-internal + jq -n \ + --arg session "${SESSION_ID:-unknown}" \ + --arg mode "${mode:-unknown}" \ + --arg root "${root:-.}" \ + --arg status "$status" \ + --arg snapshot "${snapshot:-}" \ + --arg apply "${apply:-0}" \ + --arg verify "${verify:-0}" \ + --arg require_clean_tree "${require_clean_tree_flag:-1}" \ + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg diff_patch "$SESSION_DIR/diff.patch" \ + --arg diff_stat "$SESSION_DIR/diff.stat" \ + --argjson plannedChanges "$planned_json" \ + --argjson baselineDirtyFiles "$baseline_dirty_json" \ + --argjson changedFiles "$after_json" \ + --argjson sessionChangedFiles "$session_changed_json" \ + --argjson warnings "$warnings_json" \ + --argjson errors "$errors_json" \ + '{ + schema: "ai.edit-session/v1", + session: $session, + mode: $mode, + root: $root, + status: $status, + snapshot: (if $snapshot == "" then null else $snapshot end), + apply: ($apply == "1"), + verify: ($verify == "1"), + requireCleanTree: ($require_clean_tree == "1"), + ts: $ts, + plannedChanges: $plannedChanges, + baselineDirtyFiles: $baselineDirtyFiles, + changedFiles: $changedFiles, + sessionChangedFiles: $sessionChangedFiles, + warnings: $warnings, + errors: $errors, + artifacts: { + manifest: "'$manifest_path'", + diffPatch: $diff_patch, + diffStat: $diff_stat + } + }' >"$manifest_path" + + log_json "edit.manifest" "$(cat "$manifest_path")" || true +} + +emit_result_json() { + local status="$1" + local after_json session_changed_json + + after_json="$(dirty_files_json)" + session_changed_json="$(json_array_diff "$baseline_dirty_json" "$after_json")" + + jq -n \ + --arg status "$status" \ + --arg mode "${mode:-unknown}" \ + --arg root "${root:-.}" \ + --arg snapshot "${snapshot:-}" \ + --arg session_dir "${SESSION_DIR:-}" \ + --argjson apply_bool "$([[ "$apply" == "1" ]] && echo true || echo false)" \ + --argjson verify_bool "$([[ "$verify" == "1" ]] && echo true || echo false)" \ + --argjson plannedChanges "$planned_json" \ + --argjson baselineDirtyFiles "$baseline_dirty_json" \ + --argjson changedFiles "$after_json" \ + --argjson sessionChangedFiles "$session_changed_json" \ + --argjson warnings "$warnings_json" \ + --argjson errors "$errors_json" \ + --argjson maxFiles "$max_files" \ + --argjson maxReplacements "$max_replacements" \ + --argjson maxBytes "$max_bytes" \ + '{ + schema: "ai.edit/v1", + status: $status, + tool: "ai-edit", + mode: $mode, + root: $root, + apply: $apply_bool, + verify: $verify_bool, + plannedChanges: $plannedChanges, + changedFiles: $changedFiles, + baselineDirtyFiles: $baselineDirtyFiles, + sessionChangedFiles: $sessionChangedFiles, + warnings: $warnings, + errors: $errors, + limits: { + maxFiles: $maxFiles, + maxReplacements: $maxReplacements, + maxBytes: $maxBytes + }, + snapshot: (if $snapshot == "" then null else $snapshot end), + artifacts: { + sessionDir: $session_dir, + manifest: ($session_dir + "/edit-session.json"), + diffPatch: ($session_dir + "/diff.patch"), + diffStat: ($session_dir + "/diff.stat") + }, + meta: { + targetExecuted: true, + truncated: false + } + }' +} + +finish() { + local status="$1" + local exit_code="${2:-0}" + + trap - ERR + write_session_manifest "$status" || true + + if is_json_output; then + emit_result_json "$status" + else + case "$status" in + dry_run) printf '\nDry-run only. Re-run with --apply or APPLY=1 to modify files.\n' ;; + no_matches) printf 'No matches.\n' ;; + applied) printf 'Applied changes. Manifest: %s/edit-session.json\n' "$SESSION_DIR" ;; + verified) printf 'Applied and verified. Manifest: %s/edit-session.json\n' "$SESSION_DIR" ;; + limit_exceeded | blocked | error | verify_failed) printf '%s\n' "$status" >&2 ;; + esac + fi + + exit "$exit_code" +} + +# shellcheck disable=SC2329 # invoked indirectly via `trap on_error ERR` +on_error() { + local exit_code=$? + trap - ERR + add_error "unexpected failure" + finish "error" "$exit_code" +} + +fail_status() { + local status="$1" + local message="$2" + local code="${3:-2}" + add_error "$message" + finish "$status" "$code" +} + +validate_uint() { + local name="$1" value="$2" + [[ "$value" =~ ^[0-9]+$ ]] || fail_status "error" "$name must be a non-negative integer: $value" 2 +} + +resolve_ast_grep() { + if command -v ast-grep >/dev/null 2>&1; then + printf 'ast-grep\n' + return 0 + fi + if command -v sg >/dev/null 2>&1; then + printf 'sg\n' + return 0 + fi + fail_status "unavailable" "required tool not found: ast-grep or sg" 127 +} + +default_excludes=( + ".git" ".git/**" + "vendor" "vendor/**" + "node_modules" "node_modules/**" + "dist" "dist/**" + "build" "build/**" + "coverage" "coverage/**" + ".repomix-context" ".repomix-context/**" + ".cache" ".cache/**" + "*.min.*" "*.map" "*.lock" + ".env" ".env.*" + "*.pem" "*.key" "*.crt" +) + +include_globs=() +exclude_globs=() + +build_rg_args() { + rg_args=(--hidden) + local g + for g in "${default_excludes[@]}"; do + rg_args+=(-g "!$g") + done + for g in "${exclude_globs[@]}"; do + rg_args+=(-g "!$g") + done + for g in "${include_globs[@]}"; do + rg_args+=(-g "$g") + done +} diff --git a/lib/ai-edit/main.sh b/lib/ai-edit/main.sh new file mode 100644 index 0000000..7513c74 --- /dev/null +++ b/lib/ai-edit/main.sh @@ -0,0 +1,181 @@ +# shellcheck shell=bash +# shellcheck disable=SC2154,SC2034 # cross-module globals set by ai_edit_main/parse_tail via dynamic scope +# ai-edit/90-main.sh — argument dispatch and run flow (ai_edit_main). +# +# Sourced by scripts/ai/ai-edit.sh (thin loader). Not an entrypoint. The driver +# runs inside ai_edit_main so the original top-level flow (mode dispatch, session +# init, finish) is preserved exactly. Globals it sets (mode, apply, root, +# SESSION_DIR, *_json, etc.) are intentionally NOT declared local so the helper +# functions in the sibling modules see them via dynamic scope, exactly as when +# this code ran at the top level of the monolithic script. Behavior is +# byte-for-byte identical to the previous monolithic ai-edit.sh. + +ai_edit_main() { + case "${1:-}" in + --help | -h) + usage + exit 0 + ;; + --format=help) + usage + exit 0 + ;; + --format) + [[ "${2:-}" == "help" ]] && { + usage + exit 0 + } + ;; + esac + + mode="${1:-}" + [[ -n "$mode" ]] || { + usage + exit 2 + } + shift || true + + agent_session_init "ai-edit" + SESSION_ID="${SESSION_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}" + SESSION_DIR="${SESSION_DIR:-${AI_LOG_DIR:-$REPO_ROOT/.ai-sessions}/$SESSION_ID}" + mkdir -p "$SESSION_DIR" + + require_bins jq git + + apply="${APPLY:-0}" + verify="${VERIFY:-0}" + require_clean_tree_flag="${REQUIRE_CLEAN_TREE:-1}" + format="${FORMAT:-text}" + max_files="${MAX_FILES:-50}" + max_replacements="${MAX_REPLACEMENTS:-500}" + max_bytes="${MAX_BYTES:-2000000}" + snapshot="" + planned_json='[]' + warnings_json='[]' + errors_json='[]' + patch_path="" + patch_file="" + patch_changed_files_json='[]' + baseline_dirty_json="$(dirty_files_json)" + + trap on_error ERR + + case "$mode" in + ast-grep) + [[ $# -ge 3 ]] || fail_status "error" "ast-grep requires LANG PATTERN REWRITE [root]" 2 + ast_bin="$(resolve_ast_grep)" + lang="$1" + pattern="$2" + rewrite="$3" + shift 3 + parse_tail "$@" + structural_scope_guard + + if [[ "$apply" == "1" ]]; then + # shellcheck disable=SC2015 # intentional: warn-and-continue only when clean tree is NOT required + [[ "$require_clean_tree_flag" == "1" ]] && require_clean_tree || log_warn "dirty tree allowed" + snapshot="$(snapshot_create pre-edit)" + "$ast_bin" run --lang "$lang" --pattern "$pattern" --rewrite "$rewrite" "$root" --update-all + else + if is_json_output; then + "$ast_bin" run --lang "$lang" --pattern "$pattern" --rewrite "$rewrite" "$root" >"$SESSION_DIR/dry-run.txt" || true + else + "$ast_bin" run --lang "$lang" --pattern "$pattern" --rewrite "$rewrite" "$root" || true + fi + finish "dry_run" 0 + fi + ;; + + comby) + [[ $# -ge 2 ]] || fail_status "error" "comby requires MATCH REWRITE [root]" 2 + require_bins comby + match="$1" + rewrite="$2" + shift 2 + parse_tail "$@" + structural_scope_guard + + if [[ "$apply" == "1" ]]; then + # shellcheck disable=SC2015 # intentional: warn-and-continue only when clean tree is NOT required + [[ "$require_clean_tree_flag" == "1" ]] && require_clean_tree || log_warn "dirty tree allowed" + snapshot="$(snapshot_create pre-edit)" + comby "$match" "$rewrite" -matcher .generic -in-place "$root" + else + if is_json_output; then + comby "$match" "$rewrite" -matcher .generic "$root" >"$SESSION_DIR/dry-run.txt" || true + else + comby "$match" "$rewrite" -matcher .generic "$root" || true + fi + finish "dry_run" 0 + fi + ;; + + sd) + [[ $# -ge 2 ]] || fail_status "error" "sd requires FROM TO [root]" 2 + from="$1" + to="$2" + shift 2 + parse_tail "$@" + + if sd_plan; then + if [[ "$apply" == "1" ]]; then + # shellcheck disable=SC2015 # intentional: warn-and-continue only when clean tree is NOT required + [[ "$require_clean_tree_flag" == "1" ]] && require_clean_tree || log_warn "dirty tree allowed" + snapshot="$(snapshot_create pre-edit)" + sd_apply + else + finish "dry_run" 0 + fi + else + case "$?" in + 1) finish "no_matches" 0 ;; + 2) finish "limit_exceeded" 3 ;; + *) finish "error" 1 ;; + esac + fi + ;; + + patch) + [[ $# -ge 1 ]] || fail_status "error" "patch requires PATCH_FILE|- [root] [flags]" 2 + patch_path="$1" + shift 1 + parse_tail "$@" + + # patch validation/preview/apply is path-driven; --glob/--exclude do not + # apply to an explicit diff, so reject them rather than silently ignore. + structural_scope_guard + + patch_plan + + if [[ "$apply" == "1" ]]; then + # shellcheck disable=SC2015 # intentional: warn-and-continue only when clean tree is NOT required + [[ "$require_clean_tree_flag" == "1" ]] && require_clean_tree || log_warn "dirty tree allowed" + snapshot="$(snapshot_create pre-edit)" + patch_apply + else + finish "dry_run" 0 + fi + ;; + + *) + usage + fail_status "error" "unknown mode: $mode" 2 + ;; + esac + + save_diff_artifacts + + if ! is_json_output; then + show_diff + fi + + if [[ "$verify" == "1" ]]; then + if ! "$SCRIPT_DIR/ai-verify" . >"$SESSION_DIR/verify.log" 2>&1; then + add_error "verification failed; see $SESSION_DIR/verify.log" + finish "verify_failed" 1 + fi + finish "verified" 0 + fi + + finish "applied" 0 +} diff --git a/lib/ai-edit/parse.sh b/lib/ai-edit/parse.sh new file mode 100644 index 0000000..8e54a4e --- /dev/null +++ b/lib/ai-edit/parse.sh @@ -0,0 +1,115 @@ +# shellcheck shell=bash +# shellcheck disable=SC2154,SC2034 # cross-module globals set by ai_edit_main/parse_tail via dynamic scope +# ai-edit/30-parse.sh — common tail/flag parser (parse_tail). +# +# Sourced by scripts/ai/ai-edit.sh (thin loader). Not an entrypoint. Behavior is +# byte-for-byte identical to the previous monolithic ai-edit.sh. + +parse_tail() { + root="." + local root_seen=0 + + while (($# > 0)); do + case "$1" in + --help | -h) + usage + exit 0 + ;; + --format=*) + format="${1#*=}" + shift + ;; + --format) + [[ $# -ge 2 ]] || fail_status "error" "--format requires a value" 2 + format="$2" + shift 2 + ;; + --glob) + [[ $# -ge 2 ]] || fail_status "error" "--glob requires a value" 2 + include_globs+=("$2") + shift 2 + ;; + --exclude) + [[ $# -ge 2 ]] || fail_status "error" "--exclude requires a value" 2 + exclude_globs+=("$2") + shift 2 + ;; + --max-files) + [[ $# -ge 2 ]] || fail_status "error" "--max-files requires a value" 2 + max_files="$2" + shift 2 + ;; + --max-files=*) + max_files="${1#*=}" + shift + ;; + --max-replacements) + [[ $# -ge 2 ]] || fail_status "error" "--max-replacements requires a value" 2 + max_replacements="$2" + shift 2 + ;; + --max-replacements=*) + max_replacements="${1#*=}" + shift + ;; + --max-bytes) + [[ $# -ge 2 ]] || fail_status "error" "--max-bytes requires a value" 2 + max_bytes="$2" + shift 2 + ;; + --max-bytes=*) + max_bytes="${1#*=}" + shift + ;; + --dry-run) + apply=0 + shift + ;; + --apply) + apply=1 + shift + ;; + --verify) + verify=1 + shift + ;; + --no-verify) + verify=0 + shift + ;; + --require-clean-tree) + require_clean_tree_flag=1 + shift + ;; + --allow-dirty-tree) + require_clean_tree_flag=0 + shift + ;; + --*) fail_status "error" "unknown flag: $1" 2 ;; + *) + ((root_seen == 0)) || fail_status "error" "unexpected extra positional: $1" 2 + root="$1" + root_seen=1 + shift + ;; + esac + done + + case "$format" in + text | json | help) ;; + *) fail_status "error" "unknown --format value: $format" 2 ;; + esac + + validate_uint "--max-files" "$max_files" + validate_uint "--max-replacements" "$max_replacements" + validate_uint "--max-bytes" "$max_bytes" + + # Note: a trailing `[[ ... ]] && { ... }` would return non-zero when the + # test is false, and since parse_tail is called as a bare statement that + # would trip the ERR trap under `set -e`. Use an explicit if instead. + if [[ "$format" == "help" ]]; then + usage + exit 0 + fi + return 0 +} diff --git a/lib/ai-edit/plan-apply.sh b/lib/ai-edit/plan-apply.sh new file mode 100644 index 0000000..7be67d6 --- /dev/null +++ b/lib/ai-edit/plan-apply.sh @@ -0,0 +1,210 @@ +# shellcheck shell=bash +# shellcheck disable=SC2154,SC2034 # cross-module globals set by ai_edit_main/parse_tail via dynamic scope +# ai-edit/40-plan-apply.sh — per-mode planning and apply helpers. +# +# Sourced by scripts/ai/ai-edit.sh (thin loader). Not an entrypoint. Mutations +# (sd_apply, patch_apply, and the ast-grep/comby in-place runs in the dispatch) +# only execute when apply=1 with a snapshot taken first. Behavior is +# byte-for-byte identical to the previous monolithic ai-edit.sh. + +sd_plan() { + require_bins rg + build_rg_args + + local counts_file line path count bytes file_count=0 replacement_count=0 skipped_for_bytes=0 + counts_file="$SESSION_DIR/sd-counts.txt" + mkdir -p "$SESSION_DIR" + + # Capture rg's real exit code directly. Negating with `! rg ...` would reset + # $? to 0 inside the branch and lose rg's status (1 = no matches, 2 = error). + # + # Use --count-matches (total matches), not -c (matching LINES). sd replaces + # every occurrence, so a line with three matches is three replacements; -c + # would report one and let --max-replacements be silently undercounted. The + # per-line output format stays `path:count`, so the parsing loop is unchanged. + local rc=0 + rg --count-matches "${rg_args[@]}" "$from" "$root" >"$counts_file" || rc=$? + if ((rc != 0)); then + ((rc == 1)) && return 1 + fail_status "error" "rg failed while planning replacements" "$rc" + fi + + while IFS= read -r line; do + [[ -n "$line" ]] || continue + path="${line%:*}" + count="${line##*:}" + [[ -f "$path" ]] || continue + + bytes="$(wc -c <"$path" | tr -d ' ')" + if ((bytes > max_bytes)); then + skipped_for_bytes=1 + add_warning "skipped oversized file: $path" + continue + fi + + file_count=$((file_count + 1)) + replacement_count=$((replacement_count + count)) + + planned_json="$( + jq -c \ + --arg path "$path" \ + --argjson replacements "$count" \ + --argjson bytes "$bytes" \ + '. + [{path:$path, replacements:$replacements, bytes:$bytes}]' \ + <<<"$planned_json" + )" + done <"$counts_file" + + if ((file_count == 0)); then + ((skipped_for_bytes == 1)) && return 2 + return 1 + fi + + ((file_count <= max_files)) || { + add_error "max-files exceeded: $file_count > $max_files" + return 2 + } + + ((replacement_count <= max_replacements)) || { + add_error "max-replacements exceeded: $replacement_count > $max_replacements" + return 2 + } + + return 0 +} + +sd_apply() { + require_bins sd + local path + while IFS= read -r path; do + [[ -n "$path" ]] || continue + sd "$from" "$to" "$path" + done < <(jq -r '.[].path' <<<"$planned_json") +} + +structural_scope_guard() { + if ((${#include_globs[@]} > 0 || ${#exclude_globs[@]} > 0)); then + fail_status "blocked" "$mode does not yet support --glob/--exclude safely; scope by root instead" 4 + fi +} + +# --- patch mode: apply an agent-supplied unified diff transactionally --------- + +# Deny list for patch targets. A unified diff can name ANY path, so patch mode +# needs its own guard independent of the rg-based default_excludes (which only +# affect sd discovery). Secrets, key material, and common binary blobs must +# never be written via an opaque agent-supplied diff. +patch_denied_globs=( + ".env" ".env.*" + "*.pem" "*.key" "*.crt" "*.p12" "*.pfx" + "*.sqlite" "*.db" + "*.png" "*.jpg" "*.jpeg" "*.gif" "*.webp" "*.ico" "*.pdf" + "*.zip" "*.tar" "*.gz" "*.tgz" "*.bz2" "*.xz" "*.7z" +) + +# Materialize the incoming patch into the session dir so we have a stable, +# inspectable artifact regardless of stdin vs file input. +patch_materialize() { + local input="$1" + + mkdir -p "$SESSION_DIR" + patch_file="$SESSION_DIR/agent.patch" + + if [[ "$input" == "-" ]]; then + cat >"$patch_file" + else + [[ -f "$input" ]] || fail_status "error" "patch file not found: $input" 2 + cp -- "$input" "$patch_file" + fi + + [[ -s "$patch_file" ]] || fail_status "error" "patch is empty" 2 +} + +# Emit the destination paths the patch would write. git apply --numstat prints +# `addeddeletedpath`; renames render as `old => new` (or brace form) +# and we keep the destination. /dev/null deletions are dropped. +patch_changed_paths() { + git apply --numstat "$patch_file" 2>/dev/null | + awk -F'\t' ' + NF >= 3 { + path = $3 + # rename forms: "old => new" and "pre{old => new}post" + if (index(path, "=>") > 0) { + sub(/^.*=> /, "", path) + gsub(/[{}]/, "", path) + } + if (path != "/dev/null" && path != "") + print path + } + ' | + sort -u +} + +# Block destinations that are unsafe to write from an opaque diff. +patch_guard_paths() { + local paths bad="" p g matched + paths="$(patch_changed_paths)" + + while IFS= read -r p; do + [[ -n "$p" ]] || continue + case "$p" in + /* | ../* | */../* | .git | .git/*) + bad+="$p"$'\n' + continue + ;; + esac + matched=0 + for g in "${patch_denied_globs[@]}"; do + # shellcheck disable=SC2053 # intentional glob match, not literal compare + if [[ "$(basename -- "$p")" == $g || "$p" == $g ]]; then + matched=1 + break + fi + done + ((matched == 1)) && bad+="$p"$'\n' + done <<<"$paths" + + if [[ -n "$bad" ]]; then + add_error "patch contains unsafe or protected paths" + while IFS= read -r p; do + [[ -n "$p" ]] && add_error "unsafe patch path: $p" + done <<<"$bad" + finish "blocked" 4 + fi +} + +patch_plan() { + require_bins git jq + patch_materialize "$patch_path" + patch_guard_paths + + if ! git apply --check "$patch_file" >/dev/null 2>"$SESSION_DIR/patch-check.log"; then + add_error "patch does not apply cleanly; see $SESSION_DIR/patch-check.log" + finish "blocked" 4 + fi + + patch_changed_files_json="$(patch_changed_paths | jq -R . | jq -s -c .)" + + local file_count + file_count="$(jq 'length' <<<"$patch_changed_files_json")" + + ((file_count > 0)) || finish "no_matches" 0 + + ((file_count <= max_files)) || { + add_error "max-files exceeded: $file_count > $max_files" + finish "limit_exceeded" 3 + } + + planned_json="$( + jq -c \ + --argjson files "$patch_changed_files_json" \ + '$files | map({path: ., replacements: null, bytes: null, operation: "patch"})' \ + <<<'[]' + )" +} + +patch_apply() { + require_bins git + # --whitespace=warn (not fix): never silently rewrite agent-supplied content. + git apply --whitespace=warn "$patch_file" +} diff --git a/lib/ai-search/backend-ast.sh b/lib/ai-search/backend-ast.sh new file mode 100644 index 0000000..82b4fdc --- /dev/null +++ b/lib/ai-search/backend-ast.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# 85-backend-ast.sh — structural (ast-grep) backend. +# +# Purpose: run_ast_mode for struct/symbols/class — emits structured results[] +# with name/kind/path/start/end/language, and (for symbols/class) an extra +# symbols[] array. Emits + exits directly. +# Allowed dependencies: ast-grep, jq; command_exists()/canonical_root()/ +# emit_json()/to_json_array() (common.sh + 40-output-json.sh). Reads lang_flag, +# AI_LANG, query, mode, root, g_max_results. +# +# SC2034/SC2154: query/mode/root/lang/g_* are run-state globals; `rc` mirrors +# the pre-split backend pattern (captured, not re-checked). +# shellcheck disable=SC2034,SC2154 + +# run_ast_mode — Phase 5 structural search via ast-grep, emitting structured +# results[] with name/kind/path/start/end/language. +run_ast_mode() { + # Fail closed: ast-grep has no safe grep/text equivalent (AST semantics differ), + # so do NOT silently degrade. Point the caller to the text fallback instead. + command_exists ast-grep || + fail "unavailable" "ast-grep not installed; '$mode' mode unavailable (no safe text fallback). Use: ai-search.sh text \"$query\" . --fixed" + + local lang="${lang_flag:-${AI_LANG:-php}}" + local pattern kind="" + + case "$mode" in + struct) + pattern="$query" + ;; + class) + kind="class" + pattern="class $query" + ;; + symbols) + # Resolve a bare name to its definition. Default to class def; callers + # use the dedicated shortcuts for other kinds. + kind="class" + pattern="class $query" + ;; + esac + + local out rc=0 root_abs + out="$(ast-grep run --lang "$lang" --pattern "$pattern" --json "$root" 2>/dev/null)" || rc=$? + root_abs="$(canonical_root "$root")" + + g_results_json="$(printf '%s' "$out" | jq -c \ + --argjson n "$g_max_results" \ + --arg mode "$g_mode" \ + --arg lang "$lang" \ + --arg kind "$kind" \ + --arg query "$query" \ + --arg root "$root_abs" ' + def relpath($p): ($p|if type=="string" then . else "" end) as $s + | if ($root != "" and ($s | startswith($root + "/"))) then $s[($root|length+1):] else $s end; + (if type == "array" then . else [] end) + | .[:$n] + | map({ + path: relpath(.file), + text: .text, + start: ((.range.start.line // 0) + 1), + end: ((.range.end.line // 0) + 1), + language: $lang, + mode: $mode, + source_tool: "ast-grep" + } + + (if $kind != "" then { + kind: $kind, + name: ((.metaVariables.single.NAME.text) // $query) + } else {} end)) + ')" + + local matches_json status + matches_json="$(printf '%s' "$g_results_json" | + jq '[.[] | (.path + ":" + (.start|tostring) + ":" + (.name // .text))]')" + status="ok" + [[ "$(printf '%s' "$g_results_json" | jq 'length')" -eq 0 ]] && status="no_matches" + + if [[ "$mode" == "symbols" || "$mode" == "class" ]]; then + # Symbol modes publish symbols[] in addition to results[]. + if [[ "$json_mode" == "json" ]]; then + local symbols_json + symbols_json="$g_results_json" + jq -cn \ + --arg schema "1" --arg status "$status" --arg tool "ai-search" \ + --arg query "$g_query" --arg mode "$g_mode" \ + --argjson results "$g_results_json" \ + --argjson symbols "$symbols_json" \ + --argjson matches "$matches_json" \ + --argjson warnings "$(to_json_array "${g_warnings[@]+"${g_warnings[@]}"}")" \ + --argjson max_results "$g_max_results" ' + { + schema: $schema, status: $status, tool: $tool, + query: $query, mode: $mode, + matches: $matches, results: $results, symbols: $symbols, + warnings: $warnings, errors: [], + limits: { max_results: $max_results }, + meta: { returned: ($results|length), truncated: false } + }' + exit 0 + fi + fi + + if [[ "$json_mode" == "json" ]]; then + emit_json "$status" "$matches_json" + else + printf '%s' "$g_results_json" | jq -r '.[] | "\(.path):\(.start):\(.text)"' + fi + exit 0 +} diff --git a/lib/ai-search/backend-curated.sh b/lib/ai-search/backend-curated.sh new file mode 100644 index 0000000..a3e87a8 --- /dev/null +++ b/lib/ai-search/backend-curated.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# 80-backend-curated.sh — curated no-query backends (todo, unsafe-patterns). +# +# Purpose: run_todo_mode (grouped TODO/FIXME/legacy markers) and +# run_unsafe_patterns_mode (curated risky patterns with rule + severity). +# Both build their own results[] shapes and emit + exit directly. +# Allowed dependencies: rg, jq; canonical_root()/emit_json()/fail() +# (40-output-json.sh). Reads ignore_args, rg_scope_args, root. +# +# Note: the approval-gated unsafe-all mode is short-circuited earlier in +# normalize_legacy_alias (35-parse-positionals.sh); it never reaches a scan. +# +# SC2154: ignore_args/rg_scope_args/root/json_mode are run-state globals. +# shellcheck disable=SC2154 + +run_todo_mode() { + local tag_re='TODO|FIXME|HACK|XXX|deprecated|temporary|workaround|legacy' + local out rc=0 + out="$(rg --json --ignore-case "${ignore_args[@]+"${ignore_args[@]}"}" "${rg_scope_args[@]}" -e "$tag_re" "$root" 2>/dev/null)" || rc=$? + [[ "$rc" -eq 2 ]] && fail "error" "todo scan backend error" + local root_abs + root_abs="$(canonical_root "$root")" + + g_results_json="$(printf '%s' "$out" | jq -s -R \ + --arg root "$root_abs" ' + def relpath($p): ($p|if type=="string" then . else "" end) as $s + | if ($root != "" and ($s | startswith($root + "/"))) then $s[($root|length+1):] else $s end; + [ splits("\n") | select(length>0) | (fromjson? // empty) ] + | map(select(.type == "match")) + | map({ + path: relpath(.data.path.text), + line: .data.line_number, + text: (.data.lines.text | if type=="string" then . else "" end | rtrimstr("\n")) + }) + | group_by(.path) + | map({ + path: .[0].path, + matches: map({ + tag: ( + (.text | ascii_downcase) as $lt + | if ($lt|contains("todo")) then "TODO" + elif ($lt|contains("fixme")) then "FIXME" + elif ($lt|contains("hack")) then "HACK" + elif ($lt|contains("xxx")) then "XXX" + elif ($lt|contains("deprecated")) then "deprecated" + elif ($lt|contains("temporary")) then "temporary" + elif ($lt|contains("workaround")) then "workaround" + elif ($lt|contains("legacy")) then "legacy" + else null end + ), + line: .line, + text: .text + }) + }) + ')" + + local matches_json status + matches_json="$(printf '%s' "$g_results_json" | jq '[.[].path]')" + status="ok" + [[ "$(printf '%s' "$g_results_json" | jq 'length')" -eq 0 ]] && status="no_matches" + + if [[ "$json_mode" == "json" ]]; then + emit_json "$status" "$matches_json" + else + printf '%s' "$g_results_json" | jq -r '.[].path' + fi + exit 0 +} + +run_unsafe_patterns_mode() { + # Curated risky patterns with a rule label and severity. Not a free scan. + local rules=( + 'eval\(|rule=eval|high' + 'unserialize\(|rule=unserialize|high' + 'system\(|rule=system|high' + 'exec\(|rule=exec|high' + 'shell_exec\(|rule=shell_exec|high' + 'md5\(|rule=weak-hash|medium' + 'mt_rand\(|rule=weak-random|low' + ) + local pattern_args=() spec re + for spec in "${rules[@]}"; do + re="${spec%%|rule=*}" + pattern_args+=(-e "$re") + done + + local out rc=0 + out="$(rg --json "${ignore_args[@]+"${ignore_args[@]}"}" "${rg_scope_args[@]}" "${pattern_args[@]}" "$root" 2>/dev/null)" || rc=$? + [[ "$rc" -eq 2 ]] && fail "error" "unsafe-patterns scan backend error" + local root_abs + root_abs="$(canonical_root "$root")" + + g_results_json="$(printf '%s' "$out" | jq -s -R \ + --arg root "$root_abs" ' + def relpath($p): ($p|if type=="string" then . else "" end) as $s + | if ($root != "" and ($s | startswith($root + "/"))) then $s[($root|length+1):] else $s end; + def classify($t): + if ($t|contains("eval(")) then {rule:"eval", severity:"high"} + elif ($t|contains("unserialize(")) then {rule:"unserialize", severity:"high"} + elif ($t|contains("system(")) then {rule:"system", severity:"high"} + elif ($t|contains("shell_exec(")) then {rule:"shell_exec", severity:"high"} + elif ($t|contains("exec(")) then {rule:"exec", severity:"high"} + elif ($t|contains("md5(")) then {rule:"weak-hash", severity:"medium"} + elif ($t|contains("mt_rand(")) then {rule:"weak-random", severity:"low"} + else {rule:"unsafe", severity:"medium"} end; + [ splits("\n") | select(length>0) | (fromjson? // empty) ] + | map(select(.type == "match")) + | map( + (.data.lines.text | if type=="string" then . else "" end | rtrimstr("\n")) as $t + | (classify($t)) as $c + | { + path: relpath(.data.path.text), + line: .data.line_number, + text: $t, + rule: $c.rule, + severity: $c.severity + } + ) + ')" + + local matches_json status + matches_json="$(printf '%s' "$g_results_json" | + jq '[.[] | (.path + ":" + (.line|tostring) + ":" + .rule)]')" + status="ok" + [[ "$(printf '%s' "$g_results_json" | jq 'length')" -eq 0 ]] && status="no_matches" + + if [[ "$json_mode" == "json" ]]; then + emit_json "$status" "$matches_json" + else + printf '%s' "$g_results_json" | jq -r '.[] | "\(.path):\(.line):\(.rule)"' + fi + exit 0 +} diff --git a/lib/ai-search/backend-files.sh b/lib/ai-search/backend-files.sh new file mode 100644 index 0000000..6c2a661 --- /dev/null +++ b/lib/ai-search/backend-files.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# 65-backend-files.sh — file-list and filename backends. +# +# Purpose: changed-files / staged-files (git name-only listings) and files +# (fd filename search). Each sets the global `out` to newline-delimited paths, +# consumed by the shared output assembly in 95-dispatch.sh. +# Allowed dependencies: git, fd (find_fd_bin from common.sh), require_git_root +# (60-guards.sh), fail() (40-output-json.sh). Reads root and ignore_args. +# +# SC2034/SC2154: root/query/ignore_args are run-state globals; `out` is the +# shared backend output consumed by 95-dispatch.sh. +# shellcheck disable=SC2034,SC2154 + +backend_changed_files() { + require_git_root + out="$(git -C "$root" diff --name-only 2>/dev/null | tr -d '\r' || true)" +} + +backend_staged_files() { + require_git_root + out="$(git -C "$root" diff --name-only --cached 2>/dev/null | tr -d '\r' || true)" +} + +backend_files() { + local fd_bin fd_ignore_args _ia + fd_bin="$(find_fd_bin)" + if [[ -z "$fd_bin" ]]; then + backend_files_fallback + return + fi + # Translate the rg-style ignore flags to fd-compatible ones. fd shares + # --no-ignore/--no-ignore-vcs/--no-ignore-parent; it has no separate + # --no-ignore-global/--no-ignore-dot, so those map up to --no-ignore. + fd_ignore_args=() + for _ia in "${ignore_args[@]+"${ignore_args[@]}"}"; do + case "$_ia" in + --no-ignore | --no-ignore-vcs | --no-ignore-parent) fd_ignore_args+=("$_ia") ;; + --no-ignore-global | --no-ignore-dot) fd_ignore_args+=(--no-ignore) ;; + esac + done + out="$("$fd_bin" --hidden "${fd_ignore_args[@]+"${fd_ignore_args[@]}"}" --exclude .git -- "$query" "$root" 2>/dev/null || true)" +} + +# backend_files_fallback — degrade `files` mode when fd/fdfind is absent. +# +# Preference: `git ls-files` (tracked files only) inside a git repo, otherwise +# POSIX `find`. The match is a case-insensitive substring on the file path, which +# is NOT identical to fd's smart-case regex/glob — so a parity warning is always +# emitted and the result is intentionally conservative. +backend_files_fallback() { + local query_lc results="" + # Empty query means "list everything" (mirrors fd with no pattern). + query_lc="$(printf '%s' "$query" | tr '[:upper:]' '[:lower:]')" + + if git -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + add_warning "fd/fdfind not installed; files mode degraded to 'git ls-files' (tracked files only; substring match, not fd regex/glob)" + results="$(git -C "$root" ls-files 2>/dev/null | tr -d '\r' || true)" + elif command_exists find; then + add_warning "fd/fdfind not installed; files mode degraded to POSIX 'find' (substring match, not fd regex/glob)" + results="$(find "$root" -type f -not -path '*/.git/*' 2>/dev/null | sed "s#^${root%/}/##" || true)" + else + fail "unavailable" "fd/fdfind not installed and no git/find fallback available; files mode unavailable" + fi + + if [[ -n "$query_lc" ]]; then + out="$(printf '%s\n' "$results" | awk -v q="$query_lc" 'tolower($0) ~ q' || true)" + else + out="$results" + fi +} diff --git a/lib/ai-search/backend-git.sh b/lib/ai-search/backend-git.sh new file mode 100644 index 0000000..774b03f --- /dev/null +++ b/lib/ai-search/backend-git.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# 75-backend-git.sh — git-aware bespoke backends (diff, history). +# +# Purpose: run_diff_mode (unified-diff added-line search with marker/new_line/ +# scope) and run_history_mode (log -S/-G pickaxe over revision metadata, with +# optional --patch). These build their own results[] shapes and emit + exit +# directly, because their output does not fit the path:line:text pipeline. +# Both are read-only history queries (log/show/diff), never history mutations. +# Allowed dependencies: git, awk, grep, jq; require_git_root (60-guards.sh), +# emit_json()/fail() (40-output-json.sh). Reads diff/history flags + query. +# +# SC2034/SC2154: pattern/case/query/root/diff/history globals and g_summary_json +# are run-state owned across modules (see ai-search.sh load order). +# shellcheck disable=SC2034,SC2154 + +# query_matches_line LINE — return 0 when the parsed query matches the given +# text under the active pattern/case mode. Used by diff/history line filters. +query_matches_line() { + local line="$1" grep_args=() + case "$pattern_mode" in + fixed) grep_args+=(-F) ;; + pcre2) grep_args+=(-P) ;; + *) grep_args+=(-E) ;; + esac + case "$case_mode" in + ignore) grep_args+=(-i) ;; + sensitive) : ;; + smart | *) [[ "$query" =~ [[:upper:]] ]] || grep_args+=(-i) ;; + esac + printf '%s' "$line" | grep -q "${grep_args[@]}" -- "$query" +} + +run_diff_mode() { + require_git_root + local repo_root diff_out git_args=() + repo_root="$(git -C "$root" rev-parse --show-toplevel 2>/dev/null)" || + fail "error" "not a git repository: $root" + + if [[ -n "$diff_base" ]]; then + git_args=(diff "$diff_base") + elif [[ "$diff_staged" -eq 1 ]]; then + git_args=(diff --cached) + else + git_args=(diff) + fi + + diff_out="$(cd "$repo_root" && git "${git_args[@]}" -U0 2>/dev/null || true)" + + # Walk the unified diff: track current file from +++ headers and the new + # line number from @@ hunk headers; collect added lines matching the query. + local results + results="$( + printf '%s\n' "$diff_out" | awk ' + /^\+\+\+ / { + p = $2; sub(/^b\//, "", p); cur = p; next + } + /^@@ / { + # @@ -a,b +c,d @@ -> new-file start = c + match($0, /\+[0-9]+/); ns = substr($0, RSTART+1, RLENGTH-1); + new_line = ns + 0; next + } + /^\+/ && !/^\+\+\+/ { + text = substr($0, 2); + printf "%s\t%d\t%s\n", cur, new_line, text; + new_line++; next + } + /^ / { new_line++; next } + ' + )" + + local result_objs=() path line text + while IFS=$'\t' read -r path line text; do + [[ -n "$path" ]] || continue + query_matches_line "$text" || continue + result_objs+=("$(jq -cn \ + --arg path "$path" --argjson new_line "$line" --arg text "$text" \ + '{path: $path, marker: "+", new_line: $new_line, text: $text}')") + done <<<"$results" + + local scope="unstaged" + [[ "$diff_staged" -eq 1 ]] && scope="staged" + [[ -n "$diff_base" ]] && scope="base:$diff_base" + + g_results_json="$(printf '%s\n' "${result_objs[@]:-}" | + jq -s 'map(select(. != null))')" + g_results_json="$(printf '%s' "$g_results_json" | + jq --arg scope "$scope" 'map(.scope = $scope)')" + + local matches_json status + matches_json="$(printf '%s' "$g_results_json" | + jq '[.[] | (.path + ":" + (.new_line|tostring) + ":" + .text)]')" + status="ok" + [[ "$(printf '%s' "$matches_json" | jq 'length')" -eq 0 ]] && status="no_matches" + + if [[ "$json_mode" == "json" ]]; then + g_summary_json="$(printf '%s' "$g_results_json" | + jq -c '{scope: (.[0].scope // null)}')" + emit_json "$status" "$matches_json" + else + printf '%s' "$g_results_json" | jq -r '.[] | "\(.path):\(.new_line):\(.text)"' + fi + exit 0 +} + +run_history_mode() { + require_git_root + local repo_root log_args=() raw + repo_root="$(git -C "$root" rev-parse --show-toplevel 2>/dev/null)" || + fail "error" "not a git repository: $root" + + # Field-separated commit metadata; %x1f unit separator, %x1e record sep. + local fmt='%H%x1f%an%x1f%aI%x1f%s' + + if [[ "$history_messages" -eq 1 ]]; then + log_args=(log "--grep=$query" "--format=$fmt") + [[ "$pattern_mode" == "fixed" ]] && log_args+=(--fixed-strings) + [[ "$case_mode" == "ignore" ]] && log_args+=(-i) + elif [[ "$pattern_mode" == "regex" || "$pattern_mode" == "pcre2" ]]; then + log_args=(log "-G$query" "--format=$fmt" --name-only) + else + # Default/fixed: -S pickaxe is literal by default. + log_args=(log "-S$query" "--format=$fmt" --name-only) + fi + + raw="$(cd "$repo_root" && git "${log_args[@]}" 2>/dev/null || true)" + + # Parse: a metadata line (contains \x1f) starts a commit; subsequent plain + # lines are file paths (present when --name-only is used). + local commits_json + commits_json="$( + printf '%s\n' "$raw" | jq -R -s --arg us $'\x1f' ' + split("\n") + | reduce .[] as $line ({commits: [], cur: null}; + if ($line | contains($us)) then + (if .cur != null then .commits += [.cur] else . end) + | ($line | split($us)) as $f + | .cur = { + commit: $f[0], author: $f[1], date: $f[2], + message: $f[3], files: [] + } + elif ($line | length) > 0 and (.cur != null) then + .cur.files += [$line] + else . end + ) + | (if .cur != null then .commits += [.cur] else . end) + | .commits + ' + )" + + # Expand to one result per (commit, file). When no files (message search), + # keep a single row with the commit-level path null. + local results_json + results_json="$(printf '%s' "$commits_json" | jq -c ' + map( + . as $c + | if (($c.files // []) | length) > 0 then + ($c.files[] | { commit: $c.commit, author: $c.author, + date: $c.date, message: $c.message, path: . }) + else + { commit: $c.commit, author: $c.author, date: $c.date, + message: $c.message, path: null } + end + ) + ')" + + if [[ "$history_patch" -eq 1 ]]; then + # Attach the commit patch text on request only. + local enriched=() row commit_hash patch + while IFS= read -r row; do + [[ -n "$row" ]] || continue + commit_hash="$(printf '%s' "$row" | jq -r '.commit')" + patch="$(cd "$repo_root" && git show --format= --patch "$commit_hash" 2>/dev/null || true)" + enriched+=("$(printf '%s' "$row" | jq -c --arg p "$patch" '.patch = $p')") + done < <(printf '%s' "$results_json" | jq -c '.[]') + results_json="$(printf '%s\n' "${enriched[@]:-}" | jq -s 'map(select(. != null))')" + fi + + g_results_json="$results_json" + local matches_json status + matches_json="$(printf '%s' "$g_results_json" | + jq '[.[] | (.commit + " " + (.message // ""))]')" + status="ok" + [[ "$(printf '%s' "$g_results_json" | jq 'length')" -eq 0 ]] && status="no_matches" + + if [[ "$json_mode" == "json" ]]; then + emit_json "$status" "$matches_json" + else + printf '%s' "$g_results_json" | jq -r '.[] | "\(.commit) \(.message)"' + fi + exit 0 +} diff --git a/lib/ai-search/backend-text.sh b/lib/ai-search/backend-text.sh new file mode 100644 index 0000000..46d2d19 --- /dev/null +++ b/lib/ai-search/backend-text.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# 70-backend-text.sh — content-search backends. +# +# Purpose: text (rg --json), docs/tests/config/deps (surface-scoped rg), +# tracked (git grep), and changed-text/staged-text (rg over the changed or +# staged file set via search_git_scoped_files). Each sets the global `out`, +# consumed by the shared output assembly in 95-dispatch.sh. +# Allowed dependencies: rg, git grep; require_git_root (60-guards.sh), +# surface_globs (25-modes.sh), fail() (40-output-json.sh). Reads case_args, +# rg_fixed_args, ignore_args, rg_scope_args, query, root, mode. +# +# SC2034/SC2154: case/pattern/scope/ignore/query/root/mode are run-state +# globals; `out` is the shared backend output consumed by 95-dispatch.sh. +# shellcheck disable=SC2034,SC2154 + +# search_git_scoped_files SCOPE — content search restricted to the changed or +# staged file set, run from the repository root so reported paths are +# repo-relative. Sets the global `out`. +search_git_scoped_files() { + local scope="$1" repo_root rc=0 + local files=() + local cleaned=() + local f + + repo_root="$(git -C "$root" rev-parse --show-toplevel 2>/dev/null)" || + fail "error" "not a git repository: $root" + + case "$scope" in + changed) + mapfile -d '' files < <(git -C "$repo_root" diff --name-only -z --) + ;; + staged) + mapfile -d '' files < <(git -C "$repo_root" diff --name-only --cached -z --) + ;; + *) + fail "error" "unknown scoped search: $scope" + ;; + esac + + for f in "${files[@]}"; do + [[ -n "$f" ]] && cleaned+=("$f") + done + + if [[ ${#cleaned[@]} -eq 0 ]]; then + out="" + return 0 + fi + + # -H forces path prefixes even when a single file is searched, so matches + # stay in the canonical "path:line:text" shape. + out="$(cd "$repo_root" && rg "${case_args[@]}" "${rg_fixed_args[@]}" -H -n -- "$query" "${cleaned[@]}" 2>/dev/null)" || rc=$? + + if [[ "$rc" -eq 2 ]]; then + fail "error" "search backend error in $scope files: $query" + fi + + return 0 +} + +backend_changed_text() { + require_git_root + search_git_scoped_files changed +} + +backend_staged_text() { + require_git_root + search_git_scoped_files staged +} + +backend_tracked() { + require_git_root + # git grep does not support rg's --smart-case/--pcre2 spellings, so map the + # case/pattern modes to git-grep-compatible flags here. + local git_grep_args=() rc=0 + case "$case_mode" in + ignore) git_grep_args+=(-i) ;; + sensitive) : ;; + smart | *) [[ "$query" =~ [[:upper:]] ]] || git_grep_args+=(-i) ;; + esac + case "$pattern_mode" in + fixed) git_grep_args+=(--fixed-strings) ;; + pcre2) git_grep_args+=(-P) ;; + *) : ;; + esac + out="$(git -C "$root" grep "${git_grep_args[@]}" -n -- "$query" 2>/dev/null)" || rc=$? + # git grep: 0 = match, 1 = no match, >=2 = error. + [[ "$rc" -ge 2 ]] && fail "error" "git grep error for query: $query" + return 0 +} + +# g_text_fallback — set to 1 when backend_text degraded to git grep (line output) +# so the dispatch parses `out` as path:line:text instead of rg --json. +g_text_fallback=0 + +backend_text() { + local rc=0 + if ! command_exists rg; then + backend_text_fallback + return + fi + out="$(rg --json "${case_args[@]}" "${rg_fixed_args[@]}" "${ignore_args[@]+"${ignore_args[@]}"}" "${rg_scope_args[@]}" -- "$query" "$root" 2>/dev/null)" || rc=$? + # rg: 0 = match, 1 = no match, 2 = error. + [[ "$rc" -eq 2 ]] && fail "error" "search backend error (invalid regex or unreadable path): $query" + return 0 +} + +# backend_text_fallback — degrade `text` mode to git grep when rg is absent. +# Emits line-oriented path:line:text (parsed via the line path in dispatch) and a +# parity warning: git grep searches TRACKED files only and uses git's regex, not +# rg's. Requires a git repo (guaranteed by check_tool_guards before we get here). +backend_text_fallback() { + local git_grep_args=() rc=0 + require_git_root + g_text_fallback=1 + add_warning "rg (ripgrep) not installed; 'text' mode degraded to 'git grep' (tracked files only; git regex, not rg; default ignores/globs not applied)" + + case "$case_mode" in + ignore) git_grep_args+=(-i) ;; + sensitive) : ;; + smart | *) [[ "$query" =~ [[:upper:]] ]] || git_grep_args+=(-i) ;; + esac + case "$pattern_mode" in + fixed) git_grep_args+=(--fixed-strings) ;; + pcre2) git_grep_args+=(-P) ;; + *) : ;; + esac + + out="$(git -C "$root" grep "${git_grep_args[@]}" -n -- "$query" 2>/dev/null)" || rc=$? + # git grep: 0 = match, 1 = no match, >=2 = error. + [[ "$rc" -ge 2 ]] && fail "error" "git grep error for query: $query" + return 0 +} + +backend_surface() { + # Surface-scoped text search: same engine as `text` but restricted to the + # mode's file family via include globs (default excludes still apply). + local surface_glob_args=() _sg rc=0 + while IFS= read -r _sg; do + [[ -n "$_sg" ]] && surface_glob_args+=(--glob "$_sg") + done < <(surface_globs "$mode") + out="$(rg --json "${case_args[@]}" "${rg_fixed_args[@]}" "${ignore_args[@]+"${ignore_args[@]}"}" "${rg_scope_args[@]}" "${surface_glob_args[@]}" -- "$query" "$root" 2>/dev/null)" || rc=$? + [[ "$rc" -eq 2 ]] && fail "error" "search backend error (invalid regex or unreadable path): $query" + return 0 +} + +regex_escape_literal() { + printf '%s' "$1" | sed 's/[][(){}.^$*+?|\\]/\\&/g' +} + +backend_shortcut_text() { + local rc=0 escaped shortcut_query + escaped="$(regex_escape_literal "$query")" + case "$mode" in + function | method) + shortcut_query="\\bfunction[[:space:]]+${escaped}\\b" + ;; + interface) + shortcut_query="\\binterface[[:space:]]+${escaped}\\b" + ;; + enum) + shortcut_query="\\benum[[:space:]]+${escaped}\\b" + ;; + *) + fail "error" "unknown shortcut text mode: $mode" + ;; + esac + out="$(rg --json "${case_args[@]}" --pcre2 "${ignore_args[@]+"${ignore_args[@]}"}" "${rg_scope_args[@]}" -- "$shortcut_query" "$root" 2>/dev/null)" || rc=$? + [[ "$rc" -eq 2 ]] && fail "error" "search backend error (invalid shortcut regex): $query" + return 0 +} diff --git a/lib/ai-search/bootstrap.sh b/lib/ai-search/bootstrap.sh new file mode 100644 index 0000000..29d4600 --- /dev/null +++ b/lib/ai-search/bootstrap.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# 00-bootstrap.sh — early guards and shared-library bootstrap. +# +# Purpose: handle the `--help`/`-h` and `--introspect` fast paths BEFORE any +# search runtime is loaded, then source common.sh. These guards mirror the +# pre-split ai-search.sh head: they never execute search logic, never require +# a git repo or rg/fd/ast-grep, and exec/exit before normal dispatch. +# Allowed dependencies: common.sh (sourced here). Must be the FIRST module the +# entrypoint sources. +# +# Note: the `--help` guard runs BEFORE sourcing common.sh on purpose, so the +# human-readable help renders even when common.sh's own guards would differ. +# +# Introspection target: the pure-Bash `libexec/sh-introspect` statically parses +# ONLY the entrypoint file (it does not inline the sourced search modules), so +# the human --help (usage/examples come from the entrypoint header) is complete +# while the JSON --introspect flag list reflects the entrypoint alone. + +# Early --help|-h delegate: render the introspector's help BEFORE sourcing any +# runtime dependencies or dispatching search logic. This mirrors the +# --introspect guard below but emits the human-readable --format=help view. +# It never executes search, never sources common.sh, and does not require a git +# repo or rg/fd/ast-grep. If the introspector is unavailable it prints a minimal +# fallback rather than crashing. +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + _here="$(CDPATH='' cd -- "$(dirname -- "${AI_SEARCH_ENTRYPOINT:-${BASH_SOURCE[0]}}")" && pwd)" + _introspector="$_here/sh-introspect" + if [[ -x "$_introspector" ]]; then + exec bash "$_introspector" --format=help "$_here/ai-search" + fi + # Minimal fallback (introspector unavailable). + echo "ai-search.sh — unified repository search entrypoint" + echo "Usage: ai-search.sh MODE [QUERY] [root] [flags]" + echo "Run with --introspect for the machine-readable JSON contract." + exit 0 +fi + +# Self-introspection: machine-readable contract for this script. Never executes +# search logic; delegates to the static introspector and replaces this process +# so no normal mode dispatch runs. +# +# This runs BEFORE sourcing common.sh on purpose: common.sh carries its own +# universal --introspect guard that would otherwise introspect THIS bootstrap +# module (the sourcer) and report an empty contract. +if [[ "${1:-}" == "--introspect" ]]; then + _here="$(cd "$(dirname "${AI_SEARCH_ENTRYPOINT:-${BASH_SOURCE[0]}}")" && pwd)" + exec env AI_OUTPUT=json bash \ + "$_here/sh-introspect" "$_here/ai-search" +fi + +# shellcheck disable=SC1091 +source "$(dirname "${AI_SEARCH_ENTRYPOINT:-${BASH_SOURCE[0]}}")/../lib/common.sh" diff --git a/lib/ai-search/contract.sh b/lib/ai-search/contract.sh new file mode 100644 index 0000000..4d8ecd4 --- /dev/null +++ b/lib/ai-search/contract.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# 10-contract.sh — user-facing contract: usage() text and help summary. +# +# Purpose: hold the hand-written usage() help and the auto-generated contract +# summary printer. Pure output helpers; no search logic, no global state. +# Allowed dependencies: PHP + sh-introspect for the optional summary (degrades +# silently when absent). Resolves the entrypoint dir via AI_SEARCH_ENTRYPOINT. + +# ai-search.sh — unified repository search entrypoint. +# +# JSON mode is activated by AI_OUTPUT=json. There is no --json flag. +# +# Envelope contract: +# { +# schema, status, tool, query, mode, +# matches[], results[], warnings[], errors[], +# limits{max_results}, meta{returned,truncated} +# } +# +# `matches[]` is the legacy string array and must remain backward-compatible. +# `results[]` is the additive structured output introduced in Phase 3A. +# +# Status emitted by this script: +# ok | no_matches | error | unavailable | dry_run | blocked + +usage() { + cat <<'EOF' +ai-search.sh — unified repository search entrypoint. + +Usage: + ai-search.sh MODE [QUERY] [root] [flags] + +JSON output is activated by the AI_OUTPUT=json environment variable (no --json +flag). The envelope is: {schema,status,tool,query,mode,matches[],results[], +warnings[],errors[],limits,meta[,summary,symbols]}. Status is one of: +ok | no_matches | error | unavailable | dry_run | blocked. + +Content-search modes (QUERY required; structured results[] via rg): + text search a root (rg) + tracked git-grep over tracked files (requires git root) + changed-text search only unstaged-changed files + staged-text search only staged files + files filename search (fd; unavailable if fd absent) + +Surface-scoped content modes (QUERY required; restricted file family): + docs README*/CHANGELOG*/docs/**/*.md/*.rst/*.adoc + tests tests/**/__tests__/**/*.test.*/*.spec.*/*Test.php + config .env*/config/**/*.yaml|yml|json|toml|ini|nix/docker-compose* + deps composer/package/lock files, flake.nix, go.mod, Cargo.toml, pyproject.toml + +File-list modes (no QUERY; optional root): + changed-files list unstaged-changed files + staged-files list staged files + changed | staged deprecated aliases (warn; AI_SEARCH_STRICT=1 -> error) + +Git-aware modes: + diff QUERY unstaged hunks; --staged or --base REF; results carry + path/marker/new_line/text/scope + history QUERY git log pickaxe (-S; --regex -> -G); --messages, + --patch; results carry commit/author/date/message/path + +Curated no-query modes (optional root): + todo TODO|FIXME|HACK|XXX|deprecated|temporary|workaround|legacy, + grouped by file with tag/line/text + unsafe-patterns curated risky patterns with rule + severity + +Structural modes (ast-grep; unavailable if ast-grep absent): + struct PATTERN ast-grep pattern; --lang LANG (or AI_LANG, default php) + symbols NAME resolve a symbol; emits symbols[] (kind/name/path/start/end/language) + class NAME class definitions only (symbols[] with kind=class) + +Shortcut modes (QUERY required; read-only convenience lookups): + function NAME function definitions matching NAME + method NAME method definitions matching NAME + interface NAME interface definitions matching NAME + enum NAME enum definitions matching NAME + route QUERY route files matching QUERY + config-key QUERY config/env files matching QUERY + +Other modes: + doctor diagnostics{} of available/missing/warnings/root/git_available + unsafe-all approval-gated; always returns status=blocked + +Flags: + Pattern / case: + --fixed literal fixed-string match + --regex regex match (default) + --pcre2 PCRE2 regex + --ignore-case | -i case-insensitive + --case-sensitive force case-sensitive + --smart-case case-insensitive unless query has uppercase (default) + Scope: + --glob PATTERN include glob (repeatable) + --type NAME rg type filter (repeatable) + --exclude PATH exclude path (repeatable; on top of default excludes + vendor,node_modules,dist,build,coverage,.git) + --max-depth N bound traversal depth + --absolute add absolute_path to structured results + Ignore files (gitignore honored BY DEFAULT: local + parent + global gitignore, + .git/info/exclude, and .ignore/.rgignore; the global gitignore is resolved from + git core.excludesfile / $XDG_CONFIG_HOME/git/ignore and applied explicitly): + --no-ignore disable ALL ignore sources (local+parent+global+.ignore) + --no-ignore-vcs disable local + parent .gitignore (keep global) + --no-ignore-global disable only the global gitignore + --no-ignore-parent disable parent-directory ignore files + --no-ignore-dot disable .ignore / .rgignore files + (applied to all rg-backed modes; files mode maps the supported subset to fd; + tracked/changed-text/staged-text search an explicit file set so ignores + do not apply.) + Context (text/docs): + --context N | -C N N lines before+after + --before-context N | -B N N lines before match + --after-context N | -A N N lines after match + Output shape: + --files-with-matches | -l results[] of {path} only + summary{} + --count results[] of {path,count} + summary{} + --count-matches summary{} match totals only + Bounds: + --max-results N cap returned matches; default 100; sets meta.truncated + --max-bytes N drop context payload past N bytes; sets meta.truncated + Git-aware: + --staged diff: staged hunks + --base REF diff: against REF + --messages history: search commit messages + --patch history: attach commit patch text + Structural: + --lang LANG struct/symbols/class language + Misc: + --dry-run report dry_run without searching + --introspect print full machine-readable JSON contract (sh-introspect) + --help | -h show this help + +Examples: + AI_OUTPUT=json bash scripts/ai/ai-search.sh text TenantResolver . --fixed + AI_OUTPUT=json bash scripts/ai/ai-search.sh changed-text Tenant . --fixed + AI_OUTPUT=json bash scripts/ai/ai-search.sh diff Needle . --fixed --staged + AI_OUTPUT=json bash scripts/ai/ai-search.sh class UserService . --lang php + AI_OUTPUT=json bash scripts/ai/ai-search.sh function findUser . +EOF +} + +# Print the auto-derived compact contract summary (modes + param:type) beneath +# the hand-written usage() text. Falls back silently when the introspector or +# php is unavailable, so --help never breaks; it only ADDS a summary on success. +introspect_help_summary() { + local here tool + here="$(cd "$(dirname "${AI_SEARCH_ENTRYPOINT:-${BASH_SOURCE[0]}}")" && pwd)" + tool="$here/sh-introspect" + # Static parse of the entrypoint header (usage/examples). Falls back silently + # when the introspector is unavailable, so --help never breaks. + [[ -x "$tool" ]] || return 0 + printf '\n---\nQuick contract (auto-generated by sh-introspect):\n\n' + bash "$tool" --format=help "$here/ai-search" 2>/dev/null || true + printf '\nMachine contract: ai search --introspect\n' +} + +# normalize_legacy_alias — map deprecated `changed`/`staged` modes to the +# canonical file-list modes, warning (or erroring under strict mode). Then +# short-circuit the approval-gated unsafe-all mode. +# +# This lives in the contract module (not the parser) on purpose: sh-introspect +# statically reads the deprecation case bodies below to derive the deprecated +# modes and their replacements for --introspect / --help. Keeping it here means +# the single-file introspection target (this module) carries the full contract. +# +# Runtime dependencies (fail/add_warning, set up by later modules) are only +# invoked when ai_search_main calls this, by which point all modules are loaded. +# shellcheck disable=SC2034,SC2154 +normalize_legacy_alias() { + original_mode="$mode" + legacy_alias=0 + + case "$mode" in + changed) + legacy_alias=1 + if [[ "${AI_SEARCH_STRICT:-0}" == "1" ]]; then + fail "error" "mode 'changed' is deprecated; use 'changed-files' for file lists or 'changed-text' for content search" + fi + add_warning "mode 'changed' is deprecated; use 'changed-files' for file lists or 'changed-text' for content search" + mode="changed-files" + ;; + staged) + legacy_alias=1 + if [[ "${AI_SEARCH_STRICT:-0}" == "1" ]]; then + fail "error" "mode 'staged' is deprecated; use 'staged-files' for file lists or 'staged-text' for content search" + fi + add_warning "mode 'staged' is deprecated; use 'staged-files' for file lists or 'staged-text' for content search" + mode="staged-files" + ;; + esac + + g_mode="$mode" + + # unsafe-all is approval-gated and never executes a scan here. + if [[ "$mode" == "unsafe-all" ]]; then + fail "blocked" "unsafe-all requires approval" 0 + fi +} diff --git a/lib/ai-search/dispatch.sh b/lib/ai-search/dispatch.sh new file mode 100644 index 0000000..8ff9118 --- /dev/null +++ b/lib/ai-search/dispatch.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# 95-dispatch.sh — mode dispatch, backend selection, and output assembly. +# +# Purpose: ai_search_main orchestrates the run (state -> parse -> normalise -> +# scope -> guards -> dispatch). run_backend selects the path:line:text-style +# backend (the bespoke modes exit earlier). emit_results builds the structured +# results[], applies count/file-only shaping, bounds, and emits the envelope. +# Allowed dependencies: every earlier module. Must load LAST. +# +# SC2034/SC2154: this module orchestrates run-state globals owned across all +# sibling modules (see ai-search.sh load order). +# shellcheck disable=SC2034,SC2154 + +# run_backend — dispatch the canonical (path:line:text) backends. Bespoke modes +# (diff/history/todo/unsafe-patterns/struct/symbols/class/doctor) exit before +# this point, so only the simple `out`-setting backends remain here. +run_backend() { + case "$mode" in + changed-files) backend_changed_files ;; + staged-files) backend_staged_files ;; + changed-text) backend_changed_text ;; + staged-text) backend_staged_text ;; + tracked) backend_tracked ;; + text) backend_text ;; + docs | tests | config | deps | route | config-key) backend_surface ;; + function | method | interface | enum) backend_shortcut_text ;; + files) backend_files ;; + *) + fail "error" "unknown mode: $mode" + ;; + esac +} + +# emit_results — convert the backend `out` into the canonical envelope. Mirrors +# the pre-split JSON-output tail exactly, including count/file-only shaping and +# the match-line cap. Plain mode just prints `out`. +emit_results() { + local matches_json root_abs source_tool results_bytes count final + + if [[ "$json_mode" == "json" ]]; then + # Phase 3A/3B/3C: additive structured results for content searches. + # text/docs come from an rg --json stream (accurate column, colon-safe + # paths); tracked/changed-text/staged-text are line-oriented. + # text mode degraded to git grep (rg absent) is line-oriented, so route + # it through the line parser below instead of the rg --json parser. Use a + # local routing key so the reported g_mode ("text") is not clobbered. + local route_mode="$mode" + if [[ "$mode" == "text" && "${g_text_fallback:-0}" == "1" ]]; then + route_mode="__text_fallback" + fi + + case "$route_mode" in + text | docs | tests | config | deps | route | config-key | function | method | interface | enum) + root_abs="$(canonical_root "$root")" + matches_json="$(printf '%s' "$out" | rg_json_to_matches)" + g_results_json="$(printf '%s' "$out" | rg_json_to_results "rg" "$root_abs")" + g_results_json="$(add_context_to_results "$root_abs" "$g_results_json")" + + if [[ "$max_bytes" -gt 0 ]]; then + results_bytes="$(printf '%s' "$g_results_json" | wc -c | tr -d ' ')" + + if [[ "$results_bytes" -gt "$max_bytes" ]]; then + g_truncated=true + g_results_json="$( + printf '%s' "$g_results_json" | jq ' + map(if has("context") then .context.before = [] | .context.after = [] else . end) + ' + )" + fi + fi + ;; + tracked | changed-text | staged-text | __text_fallback) + matches_json="$(printf '%s' "$out" | lines_to_matches)" + root_abs="$(canonical_root "$root")" + source_tool="rg" + + if [[ "$route_mode" == "tracked" || "$route_mode" == "__text_fallback" ]]; then + source_tool="git-grep" + fi + + g_results_json="$(printf '%s' "$out" | lines_to_structured_results "$source_tool" "$root_abs")" + g_results_json="$(add_context_to_results "$root_abs" "$g_results_json")" + + if [[ "$max_bytes" -gt 0 ]]; then + results_bytes="$(printf '%s' "$g_results_json" | wc -c | tr -d ' ')" + + if [[ "$results_bytes" -gt "$max_bytes" ]]; then + g_truncated=true + + # Preserve match identity, but remove bulky context payload. + g_results_json="$( + printf '%s' "$g_results_json" | jq ' + map( + if has("context") then + .context.before = [] | .context.after = [] + else + . + end + ) + ' + )" + fi + fi + ;; + *) + # File-list and structural modes: plain string matches, no results[]. + matches_json="$(printf '%s' "$out" | lines_to_matches)" + g_results_json="[]" + ;; + esac + + # Phase 3D: count / file-only output. Aggregate the structured results into + # per-file rows and publish a summary, without dumping every match line. + # `matches[]` (the legacy string array) is preserved unchanged. + if [[ "$count_mode" != "none" ]]; then + g_summary_json="$( + printf '%s' "$g_results_json" | jq -c ' + { + total_files: ([.[].path] | unique | length), + total_matches: length + } + ' + )" + + case "$count_mode" in + files) + g_results_json="$( + printf '%s' "$g_results_json" | jq -c ' + [.[].path] | unique | map({ path: . }) + ' + )" + ;; + count) + g_results_json="$( + printf '%s' "$g_results_json" | jq -c ' + group_by(.path) + | map({ path: .[0].path, count: length }) + ' + )" + ;; + count-matches) + g_results_json="$( + printf '%s' "$g_results_json" | jq -c ' + group_by(.path) + | map({ path: .[0].path, count: length }) + ' + )" + ;; + esac + fi + + count="$(printf '%s' "$matches_json" | jq 'length')" + + if [[ "$count" -gt "$g_max_results" ]]; then + matches_json="$(printf '%s' "$matches_json" | jq --argjson n "$g_max_results" '.[:$n]')" + # In count modes results[] are aggregated per-file rows, not per match + # line, so the match-line cap must not truncate them. + if [[ "$count_mode" == "none" ]]; then + g_results_json="$(printf '%s' "$g_results_json" | jq --argjson n "$g_max_results" '.[:$n]')" + fi + g_truncated=true + fi + + final="$(printf '%s' "$matches_json" | jq 'length')" + + if [[ "$final" -eq 0 ]]; then + emit_json "no_matches" "$matches_json" + else + emit_json "ok" "$matches_json" + fi + else + # Plain (non-JSON) output. The JSON branch caps matches at g_max_results; + # mirror that here so a degenerate match-everything query (e.g. + # `tracked .`) cannot dump every line of every tracked file. Without this + # cap, plain mode streams the entire backend output unbounded. + local total_lines capped + if [[ -z "$out" ]]; then + return 0 + fi + total_lines="$(printf '%s\n' "$out" | wc -l | tr -d ' ')" + if [[ "$total_lines" -gt "$g_max_results" ]]; then + printf '%s\n' "$out" | head -n "$g_max_results" + printf '... (truncated: showed %s of %s matches; use --max-results N or a narrower query)\n' \ + "$g_max_results" "$total_lines" >&2 + else + printf '%s\n' "$out" + fi + fi +} + +# ai_search_main — top-level orchestrator. Receives the full original argv. +ai_search_main() { + init_run_state "${1:-}" + + if [[ "$mode" == "--help" || "$mode" == "-h" || -z "$mode" ]]; then + usage + introspect_help_summary + exit 0 + fi + + # doctor takes no query/root; report real tool diagnostics. + if [[ "$mode" == "doctor" ]]; then + run_doctor_mode + fi + + # Flags are accepted in any position. Positionals are interpreted per mode + # family after legacy-alias normalization. + shift # consume MODE + parse_flags "$@" + + normalize_legacy_alias + interpret_positionals + handle_dry_run + + build_case_pattern_args + apply_global_gitignore + build_rg_scope_args + + check_tool_guards + + # Early dispatch for bespoke result shapes. + case "$mode" in + diff) run_diff_mode ;; + history) run_history_mode ;; + todo) run_todo_mode ;; + unsafe-patterns) run_unsafe_patterns_mode ;; + struct | symbols | class) run_ast_mode ;; + esac + + run_backend + emit_results +} diff --git a/lib/ai-search/doctor.sh b/lib/ai-search/doctor.sh new file mode 100644 index 0000000..c9f2a45 --- /dev/null +++ b/lib/ai-search/doctor.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# 90-doctor.sh — doctor mode. +# +# Purpose: run_doctor_mode reports tool availability (jq/git/rg/ast-grep/fd), +# the root, and git_available as a diagnostics{} envelope. Takes no query or +# root; emits + exits directly. +# Allowed dependencies: command_exists()/find_fd_bin() (common.sh), jq, git. +# +# SC2154: json_mode is a run-state global set by 20-state.sh. +# shellcheck disable=SC2154 + +# run_doctor_mode — diagnostics for the doctor mode. Plain mode prints a short +# status line; JSON mode emits the diagnostics{} envelope. +run_doctor_mode() { + if [[ "$json_mode" != "json" ]]; then + echo "ai-search doctor: ok" + exit 0 + fi + + local available=() missing=() warnings=() + local tool fd_bin root_dir git_available + + for tool in jq git rg ast-grep; do + if command_exists "$tool"; then + available+=("$tool") + else + missing+=("$tool") + fi + done + + fd_bin="$(find_fd_bin)" + if [[ -n "$fd_bin" ]]; then + available+=("$fd_bin") + else + warnings+=("fd/fdfind not found; files mode degraded") + fi + + root_dir="." + git_available=false + if git -C "$root_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git_available=true + fi + + jq -cn \ + --arg schema "1" \ + --arg tool "ai-search" \ + --argjson available "$(printf '%s\n' "${available[@]}" | jq -R -s 'split("\n")|map(select(length>0))')" \ + --argjson missing "$(printf '%s\n' "${missing[@]:-}" | jq -R -s 'split("\n")|map(select(length>0))')" \ + --argjson warnings "$(printf '%s\n' "${warnings[@]:-}" | jq -R -s 'split("\n")|map(select(length>0))')" \ + --arg root "$root_dir" \ + --argjson git_available "$git_available" \ + '{ + schema: $schema, + status: "ok", + tool: $tool, + mode: "doctor", + diagnostics: { + available: $available, + missing: $missing, + warnings: $warnings, + root: $root, + git_available: $git_available + } + }' + + exit 0 +} diff --git a/lib/ai-search/guards.sh b/lib/ai-search/guards.sh new file mode 100644 index 0000000..a47baa8 --- /dev/null +++ b/lib/ai-search/guards.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# 60-guards.sh — tool and git-root guards. +# +# Purpose: classify which modes need rg/git (mode_needs_rg / mode_needs_git), +# enforce those tools as hard errors (check_tool_guards), and verify a git +# root for git-backed modes (require_git_root). +# Allowed dependencies: command_exists() (common.sh), fail() (40-output-json.sh). +# Reads mode and root. +# +# A missing core backend must be a hard `error`, not a silent `no_matches` (the +# backend commands suppress stderr, so a missing tool would otherwise collapse +# to rc!=2 and look like an empty result set). +# +# SC2154: mode/root are run-state globals set by sibling modules. +# shellcheck disable=SC2154 + +mode_needs_rg() { + case "$1" in + text | docs | tests | config | deps | changed-text | staged-text | \ + todo | unsafe-patterns) return 0 ;; + *) return 1 ;; + esac +} + +mode_needs_git() { + case "$1" in + changed-files | staged-files | changed-text | staged-text | tracked | \ + diff | history) return 0 ;; + *) return 1 ;; + esac +} + +# mode_has_rg_fallback — modes that can degrade to git grep when rg is absent. +# Only plain `text` qualifies: git grep yields the same path:line:text shape the +# dispatch already parses. Surface modes (docs/tests/config/deps) rely on rg glob +# scoping with no safe git-grep equivalent, so they keep the hard guard. +mode_has_rg_fallback() { + [[ "$1" == "text" ]] +} + +# check_tool_guards — fail early when a required core tool is missing. +check_tool_guards() { + if mode_needs_rg "$mode" && ! command_exists rg; then + # Allow `text` to degrade to git grep when rg is missing but git is + # present (the backend emits a parity warning). Everything else, and the + # case where git is also missing, stays a hard error. + if mode_has_rg_fallback "$mode" && command_exists git; then + : + else + fail "error" "required tool 'rg' (ripgrep) not found on PATH; mode '$mode' unavailable" + fi + fi + + if mode_needs_git "$mode" && ! command_exists git; then + fail "error" "required tool 'git' not found on PATH; mode '$mode' unavailable" + fi + return 0 +} + +require_git_root() { + # git -C requires a directory; a file path yields a misleading + # "Not a directory" / "not a git repository" message. Catch it first so the + # caller sees the real cause and the fix (scope a single file with --glob). + if [[ -e "$root" && ! -d "$root" ]]; then + fail "error" "root must be a directory (got file): $root; scope a single file with --glob" + fi + git -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1 || + fail "error" "not a git repository: $root" +} diff --git a/lib/ai-search/modes.sh b/lib/ai-search/modes.sh new file mode 100644 index 0000000..659f021 --- /dev/null +++ b/lib/ai-search/modes.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# 25-modes.sh — mode taxonomy. +# +# Purpose: classify modes into families (file-list, content, ast, no-query, +# surface) and provide surface_globs() for the surface-scoped modes. Pure +# predicates; no side effects, no global state. +# Allowed dependencies: none. + +# Mode families. File-list modes take no query; content modes require one. +is_file_list_mode() { + case "$1" in + changed-files | staged-files) return 0 ;; + *) return 1 ;; + esac +} + +is_content_mode() { + case "$1" in + text | tracked | files | struct | docs | changed-text | staged-text) return 0 ;; + # Phase 4 query-required repo-aware modes. + diff | history | tests | config | deps) return 0 ;; + # Phase 5 structural/shortcut modes take a pattern/name as the query. + symbols | class | function | method | interface | enum | route | config-key) return 0 ;; + *) return 1 ;; + esac +} + +# Phase 5 structural (ast-grep) modes. +is_ast_mode() { + case "$1" in + struct | symbols | class) return 0 ;; + *) return 1 ;; + esac +} + +# Phase 4 modes that take no query and an optional root only. +is_no_query_mode() { + case "$1" in + todo | unsafe-patterns) return 0 ;; + *) return 1 ;; + esac +} + +# Phase 4 surface-scoped text modes: search like `text` but restricted to a +# fixed glob set. `docs` is split out from `text` so it is truly scoped. +is_surface_mode() { + case "$1" in + docs | tests | config | deps | route | config-key) return 0 ;; + *) return 1 ;; + esac +} + +# surface_globs MODE — print the rg --glob include patterns for a surface mode, +# one per line. Used to restrict docs/tests/config/deps to their file families. +surface_globs() { + case "$1" in + docs) + printf '%s\n' 'README*' 'CHANGELOG*' '*.md' '*.rst' '*.adoc' 'docs/**' + ;; + tests) + printf '%s\n' 'tests/**' '__tests__/**' '*.test.*' '*.spec.*' '*Test.php' + ;; + config) + printf '%s\n' '.env*' 'config/**' '*.yaml' '*.yml' '*.json' '*.toml' \ + '*.ini' '*.nix' 'docker-compose*' + ;; + deps) + printf '%s\n' 'composer.json' 'composer.lock' 'package.json' \ + 'package-lock.json' 'pnpm-lock.yaml' 'yarn.lock' 'flake.nix' \ + 'go.mod' 'Cargo.toml' 'pyproject.toml' + ;; + route) + printf '%s\n' '**/routes/**' '*routes*' '*.routes.*' '**/app/**/routes/**' + ;; + config-key) + printf '%s\n' '.env*' 'config/**' '*.yaml' '*.yml' '*.json' '*.toml' \ + '*.ini' '*.nix' 'docker-compose*' + ;; + esac +} + +is_shortcut_text_mode() { + case "$1" in + function | method | interface | enum) return 0 ;; + *) return 1 ;; + esac +} diff --git a/lib/ai-search/output-json.sh b/lib/ai-search/output-json.sh new file mode 100644 index 0000000..a44cbde --- /dev/null +++ b/lib/ai-search/output-json.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# 40-output-json.sh — JSON envelope output and failure handling. +# +# Purpose: render the canonical envelope (emit_json), the error/blocked path +# (fail), the legacy string-array helpers (to_json_array, lines_to_matches), +# canonical_root(), and validate_non_negative_int(). +# Allowed dependencies: jq; common.sh log_error. Reads run-state globals +# (g_query, g_mode, g_results_json, g_summary_json, g_max_results, g_truncated, +# g_warnings, json_mode). +# +# Load-order constraint: emit_json() MUST be defined before fail(), because +# fail() calls emit_json() in JSON mode. +# +# SC2154: g_query/g_mode/g_results_json/g_summary_json/g_max_results/g_truncated/ +# g_warnings/json_mode are run-state globals set by sibling modules. +# shellcheck disable=SC2154 + +# to_json_array ITEMS... — render arguments as a JSON string array, dropping +# empty entries. Prints [] when called with no arguments. +to_json_array() { + if [[ "$#" -eq 0 ]]; then + printf '[]' + else + printf '%s\n' "$@" | jq -R -s 'split("\n") | map(select(length > 0))' + fi +} + +# emit_json STATUS [MATCHES_JSON] [ERRORS_JSON] [WARNINGS_JSON] +# Renders the canonical envelope. Warnings default to g_warnings. +emit_json() { + local status="$1" matches_json="${2:-[]}" errors="${3:-[]}" warnings="${4:-}" + local returned truncated="${g_truncated:-false}" + + if [[ -z "$warnings" ]]; then + warnings="$(to_json_array "${g_warnings[@]}")" + fi + + returned="$(printf '%s' "$matches_json" | jq 'length')" + + jq -cn \ + --arg schema "1" \ + --arg status "$status" \ + --arg tool "ai-search" \ + --arg query "${g_query:-}" \ + --arg mode "${g_mode:-}" \ + --argjson matches "$matches_json" \ + --argjson results "${g_results_json:-[]}" \ + --argjson errors "$errors" \ + --argjson warnings "$warnings" \ + --argjson max_results "$g_max_results" \ + --argjson returned "$returned" \ + --argjson truncated "$truncated" \ + --argjson summary "${g_summary_json:-null}" \ + '{ + schema: $schema, + status: $status, + tool: $tool, + query: $query, + mode: $mode, + matches: $matches, + results: $results, + warnings: $warnings, + errors: $errors, + limits: { max_results: $max_results }, + meta: { returned: $returned, truncated: $truncated } + } + | if $summary != null then .summary = $summary else . end' +} + +# fail STATUS MESSAGE [RC] — emit an error/blocked/unavailable envelope in JSON +# mode, or a plain stderr line otherwise, then exit. +fail() { + local status="$1" msg="$2" rc="${3:-1}" + + if [[ "$json_mode" == "json" ]]; then + emit_json "$status" "[]" "$(jq -cn --arg m "$msg" '[$m]')" + else + log_error "$msg" + fi + + exit "$rc" +} + +# lines_to_matches — turn newline-delimited backend output into a JSON string +# array, dropping empty lines. +lines_to_matches() { + jq -R -s 'split("\n") | map(select(length > 0))' +} + +canonical_root() { + local root_input="$1" + + if git -C "$root_input" rev-parse --show-toplevel >/dev/null 2>&1; then + git -C "$root_input" rev-parse --show-toplevel + else + ( + cd "$root_input" 2>/dev/null && pwd -P + ) + fi +} + +validate_non_negative_int() { + local flag="$1" + local value="$2" + + if [[ ! "$value" =~ ^[0-9]+$ ]]; then + fail "error" "$flag requires a non-negative integer" + fi +} diff --git a/lib/ai-search/parse-flags.sh b/lib/ai-search/parse-flags.sh new file mode 100644 index 0000000..e24e5c2 --- /dev/null +++ b/lib/ai-search/parse-flags.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# 30-parse-flags.sh — flag parser. +# +# Purpose: parse all flags into run-state globals. Flags are accepted in any +# position; non-flag tokens accumulate into positionals[] for later mode-aware +# interpretation. Unknown flags are a hard error. +# Allowed dependencies: fail(), validate_non_negative_int() (40-output-json.sh), +# usage()/introspect_help_summary() (10-contract.sh). Caller passes the +# already-MODE-consumed argument list. +# +# SC2034/SC2154: this module reads and writes run-state globals owned by other +# modules (see ai-search.sh load order); they are not local to this file. +# shellcheck disable=SC2034,SC2154 + +parse_flags() { + while [[ $# -gt 0 ]]; do + case "$1" in + --fixed) + pattern_mode="fixed" + ;; + --regex) + pattern_mode="regex" + ;; + --pcre2) + pattern_mode="pcre2" + ;; + --absolute) + absolute=1 + ;; + --ignore-case | -i) + case_mode="ignore" + ;; + --case-sensitive) + case_mode="sensitive" + ;; + --smart-case) + case_mode="smart" + ;; + --glob) + shift + [[ -n "${1:-}" ]] || fail "error" "--glob requires a pattern" + glob_args+=("$1") + ;; + --type) + shift + [[ -n "${1:-}" ]] || fail "error" "--type requires a type name" + type_args+=("$1") + ;; + --exclude) + shift + [[ -n "${1:-}" ]] || fail "error" "--exclude requires a path" + exclude_args+=("$1") + ;; + --max-depth) + shift + validate_non_negative_int "--max-depth" "${1:-}" + max_depth="$1" + ;; + --no-ignore) + # Disable ALL ignore sources (local + parent + global gitignore, + # .git/info/exclude, and .ignore/.rgignore files). + ignore_args+=(--no-ignore) + ;; + --no-ignore-vcs) + # Disable local + parent .gitignore and .git/info/exclude only; + # the global gitignore is still honored. + ignore_args+=(--no-ignore-vcs) + ;; + --no-ignore-global) + # Disable only the global gitignore (git core.excludesfile); + # local/parent .gitignore are still honored. + ignore_args+=(--no-ignore-global) + ;; + --no-ignore-parent) + # Disable .gitignore/.ignore files in parent directories only. + ignore_args+=(--no-ignore-parent) + ;; + --no-ignore-dot) + # Disable .ignore and .rgignore files (keep gitignore behavior). + ignore_args+=(--no-ignore-dot) + ;; + --dry-run) + dry_run=1 + ;; + --context | -C) + shift + validate_non_negative_int "--context" "${1:-}" + context_before="$1" + context_after="$1" + ;; + --before-context | -B) + shift + validate_non_negative_int "--before-context" "${1:-}" + context_before="$1" + ;; + --after-context | -A) + shift + validate_non_negative_int "--after-context" "${1:-}" + context_after="$1" + ;; + --max-bytes) + shift + validate_non_negative_int "--max-bytes" "${1:-}" + max_bytes="$1" + ;; + --max-results) + shift + validate_non_negative_int "--max-results" "${1:-}" + g_max_results="$1" + ;; + --files-with-matches | -l) + count_mode="files" + ;; + --count) + count_mode="count" + ;; + --count-matches) + count_mode="count-matches" + ;; + --staged) + diff_staged=1 + ;; + --base) + shift + [[ -n "${1:-}" ]] || fail "error" "--base requires a ref" + diff_base="$1" + ;; + --messages) + history_messages=1 + ;; + --patch) + history_patch=1 + ;; + --lang) + shift + [[ -n "${1:-}" ]] || fail "error" "--lang requires a language" + lang_flag="$1" + ;; + --help | -h) + usage + introspect_help_summary + exit 0 + ;; + --*) + fail "error" "unknown flag: $1" + ;; + *) + positionals+=("$1") + ;; + esac + shift + done + return 0 +} diff --git a/lib/ai-search/parse-positionals.sh b/lib/ai-search/parse-positionals.sh new file mode 100644 index 0000000..56cfe29 --- /dev/null +++ b/lib/ai-search/parse-positionals.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# 35-parse-positionals.sh — positional interpretation + dry-run. +# +# Purpose: interpret positionals[] into query/root per mode family and handle +# --dry-run. The deprecated-alias normaliser (normalize_legacy_alias) lives in +# 10-contract.sh so the single-file introspection target carries the full +# contract; ai_search_main calls it before interpret_positionals. +# Allowed dependencies: fail()/emit_json() (output + state), is_*_mode() +# (25-modes.sh). Reads/sets run-state globals. +# +# SC2034/SC2154: run-state globals are owned across modules (see ai-search.sh +# load order), not local to this file. +# shellcheck disable=SC2034,SC2154 + +# interpret_positionals — resolve query and root from positionals[] according +# to the mode family. +interpret_positionals() { + query="" + root="." + + if is_file_list_mode "$mode" && [[ "$legacy_alias" -eq 0 ]]; then + # Canonical file-list modes take an optional root and never a query. + if [[ ${#positionals[@]} -gt 1 ]]; then + fail "error" "mode '$mode' does not accept a query; usage: ai-search.sh $mode [root] [flags]" + fi + + if [[ ${#positionals[@]} -eq 1 ]]; then + root="${positionals[0]}" + fi + + elif is_file_list_mode "$mode" && [[ "$legacy_alias" -eq 1 ]]; then + # Legacy `changed`/`staged`: tolerate an ignored leading query so existing + # callers like `changed dummy .` keep working during migration. + case "${#positionals[@]}" in + 0) + root="." + ;; + 1) + if [[ -d "${positionals[0]}" ]]; then + root="${positionals[0]}" + else + root="." + fi + ;; + 2) + root="${positionals[1]}" + ;; + *) + fail "error" "too many positional arguments for legacy mode '$original_mode'" + ;; + esac + + elif is_content_mode "$mode"; then + if [[ ${#positionals[@]} -lt 1 ]]; then + fail "error" "query required for mode: $mode" + fi + + if [[ ${#positionals[@]} -gt 2 ]]; then + fail "error" "too many positional arguments" + fi + + query="${positionals[0]}" + + if [[ ${#positionals[@]} -eq 2 ]]; then + root="${positionals[1]}" + fi + + elif is_no_query_mode "$mode"; then + # todo / unsafe-patterns: optional root, never a query. + if [[ ${#positionals[@]} -gt 1 ]]; then + fail "error" "mode '$mode' does not accept a query; usage: ai-search.sh $mode [root] [flags]" + fi + + if [[ ${#positionals[@]} -eq 1 ]]; then + root="${positionals[0]}" + fi + + else + fail "error" "unknown mode: $mode" + fi + + g_query="$query" +} + +# handle_dry_run — when --dry-run was given, emit a dry_run envelope and exit. +handle_dry_run() { + if [[ "$dry_run" -eq 1 ]]; then + if [[ "$json_mode" == "json" ]]; then + emit_json "dry_run" + else + echo "dry-run" + fi + exit 0 + fi +} diff --git a/lib/ai-search/results-context.sh b/lib/ai-search/results-context.sh new file mode 100644 index 0000000..da1626b --- /dev/null +++ b/lib/ai-search/results-context.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# 50-results-context.sh — context-line enrichment and byte-budget trimming. +# +# Purpose: read N lines around each match (context_lines_json), attach them to +# results[] (add_context_to_results), and trim bulky context payload past the +# --max-bytes budget (apply_max_bytes_to_results). +# Allowed dependencies: jq, awk. Reads context_before/context_after/max_bytes +# and sets g_truncated. +# +# SC2034/SC2154: context_before/context_after/max_bytes/g_truncated are +# run-state globals set by sibling modules. +# shellcheck disable=SC2034,SC2154 + +context_lines_json() { + local file="$1" + local start="$2" + local end="$3" + + if [[ "$start" -gt "$end" || ! -f "$file" ]]; then + printf '[]' + return 0 + fi + + awk -v start="$start" -v end="$end" ' + NR >= start && NR <= end { + printf "%d\t%s\n", NR, $0 + } + ' "$file" | jq -R -s ' + split("\n") + | map(select(length > 0)) + | map( + capture("^(?[0-9]+)\t(?.*)$") + | .line = (.line | tonumber) + ) + ' +} + +add_context_to_results() { + local root_abs="$1" + local results_json="$2" + local result path line file before_start before_end after_start after_end + local before_json after_json enriched first output + + if [[ "$context_before" -eq 0 && "$context_after" -eq 0 ]]; then + printf '%s' "$results_json" + return 0 + fi + + first=1 + output="[" + + while IFS= read -r result; do + [[ -n "$result" ]] || continue + + path="$(printf '%s' "$result" | jq -r '.path // ""')" + line="$(printf '%s' "$result" | jq -r '.line // 0')" + + if [[ -z "$path" || "$line" -le 0 ]]; then + before_json="[]" + after_json="[]" + else + file="$root_abs/$path" + + before_start=$((line - context_before)) + before_end=$((line - 1)) + after_start=$((line + 1)) + after_end=$((line + context_after)) + + [[ "$before_start" -lt 1 ]] && before_start=1 + + before_json="$(context_lines_json "$file" "$before_start" "$before_end")" + after_json="$(context_lines_json "$file" "$after_start" "$after_end")" + fi + + enriched="$( + jq -cn \ + --argjson result "$result" \ + --argjson before "$before_json" \ + --argjson after "$after_json" \ + '$result + { context: { before: $before, after: $after } }' + )" + + if [[ "$first" -eq 1 ]]; then + output+="$enriched" + first=0 + else + output+=",$enriched" + fi + done < <(printf '%s' "$results_json" | jq -c '.[]') + + output+="]" + printf '%s' "$output" +} + +apply_max_bytes_to_results() { + local results_json="$1" + local bytes + + if [[ "$max_bytes" -eq 0 ]]; then + printf '%s' "$results_json" + return 0 + fi + + bytes="$(printf '%s' "$results_json" | wc -c | tr -d ' ')" + + if [[ "$bytes" -le "$max_bytes" ]]; then + printf '%s' "$results_json" + return 0 + fi + + g_truncated=true + + # Minimal safe truncation for Phase 3B: preserve result objects and match + # identity, but remove bulky context payload. + printf '%s' "$results_json" | jq ' + map( + if has("context") then + .context.before = [] | .context.after = [] + else + . + end + ) + ' +} diff --git a/lib/ai-search/results-rg.sh b/lib/ai-search/results-rg.sh new file mode 100644 index 0000000..acb0bae --- /dev/null +++ b/lib/ai-search/results-rg.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# 45-results-rg.sh — structured result builders for rg/git-grep output. +# +# Purpose: transform backend output into the additive results[] structures — +# line-oriented "path:line:text" (lines_to_structured_results) and rg --json +# streams (rg_json_to_results / rg_json_to_matches). The legacy matches[] +# string array stays unchanged. +# Allowed dependencies: jq. Reads g_mode and absolute via jq --arg. +# +# SC2154: g_mode/absolute are run-state globals set by sibling modules. +# shellcheck disable=SC2154 + +# Build additive structured results[] from line-oriented grep output: +# path:line:text +# +# This deliberately keeps matches[] unchanged. It uses a greedy path capture +# before :LINE: so paths containing colons remain valid. +lines_to_structured_results() { + local source_tool="$1" + local root_abs="$2" + + jq -R -s \ + --arg mode "$g_mode" \ + --arg source_tool "$source_tool" \ + --arg root "$root_abs" \ + --arg absolute "$absolute" ' + def as_string: + if type == "string" then . else "" end; + + def lang($p): + ($p | as_string) as $s + | if ($s | endswith(".php")) then "php" + elif ($s | endswith(".js")) then "js" + elif ($s | endswith(".jsx")) then "jsx" + elif ($s | endswith(".ts")) then "ts" + elif ($s | endswith(".tsx")) then "tsx" + elif ($s | endswith(".json")) then "json" + elif (($s | endswith(".yml")) or ($s | endswith(".yaml"))) then "yaml" + elif ($s | endswith(".md")) then "markdown" + elif ($s | endswith(".rst")) then "rst" + elif ($s | endswith(".adoc")) then "asciidoc" + elif ($s | endswith(".nix")) then "nix" + elif (($s | endswith(".sh")) or ($s | endswith(".bash"))) then "shell" + else null + end; + + def relpath($p): + ($p | as_string) as $s + | if ($root != "" and ($s | startswith($root + "/"))) then + $s[($root|length + 1):] + else + $s + end; + + split("\n") + | map(select(length > 0)) + | map(capture("^(?.*):(?[0-9]+):(?.*)$")?) + | map(select(. != null and (.raw_path? | type == "string") and (.line? | type == "string"))) + | map( + .path = relpath(.raw_path) + | .line = (.line | tonumber) + | .column = 1 + | .mode = $mode + | .source_tool = $source_tool + | .root = $root + | .language = lang(.path) + | if ($absolute == "1") then + .absolute_path = ( + if ((.raw_path | as_string) | startswith("/")) then + .raw_path + else + $root + "/" + .path + end + ) + else + . + end + | del(.raw_path) + ) + ' +} + +# Shared jq prelude: language detection + repo-relative path. Reused by the +# rg --json parsers below. +# shellcheck disable=SC2016 # single-quoted on purpose: $p/$s/$x are jq variables, not shell. +_rg_json_jq_prelude=' + def as_string: if type == "string" then . else "" end; + def lang($p): + ($p | as_string) as $s + | if ($s | endswith(".php")) then "php" + elif ($s | endswith(".js")) then "js" + elif ($s | endswith(".jsx")) then "jsx" + elif ($s | endswith(".ts")) then "ts" + elif ($s | endswith(".tsx")) then "tsx" + elif ($s | endswith(".json")) then "json" + elif (($s | endswith(".yml")) or ($s | endswith(".yaml"))) then "yaml" + elif ($s | endswith(".md")) then "markdown" + elif ($s | endswith(".rst")) then "rst" + elif ($s | endswith(".adoc")) then "asciidoc" + elif ($s | endswith(".nix")) then "nix" + elif (($s | endswith(".sh")) or ($s | endswith(".bash"))) then "shell" + else null + end; + def relpath($p): + ($p | as_string) as $s + | if ($root != "" and ($s | startswith($root + "/"))) then $s[($root|length + 1):] + else $s end; + [ splits("\n") | select(length > 0) | (fromjson? // empty) ] + | map(select(.type == "match")) +' + +# rg_json_to_results — parse an `rg --json` stream into structured result +# objects with accurate 1-based column from submatch byte offsets. +rg_json_to_results() { + local source_tool="$1" root_abs="$2" + jq -s -R \ + --arg mode "$g_mode" \ + --arg source_tool "$source_tool" \ + --arg root "$root_abs" \ + --arg absolute "$absolute" \ + "$_rg_json_jq_prelude"' + | map( + .data as $d + | ($d.path.text) as $raw + | { + path: relpath($raw), + line: $d.line_number, + column: (((($d.submatches[0]?.start) // 0) | floor) + 1), + text: (($d.lines.text | as_string) | rtrimstr("\n")), + mode: $mode, + source_tool: $source_tool, + root: $root, + language: lang($raw) + } + | if ($absolute == "1") then + .absolute_path = (if ($raw | startswith("/")) then $raw else ($root + "/" + .path) end) + else . end + ) + ' +} + +# rg_json_to_matches — legacy "path:line:text" string array from an rg --json +# stream (paths come from the JSON, so colon-in-filename is safe). +rg_json_to_matches() { + jq -s -R "$_rg_json_jq_prelude"' + | map( + (.data.path.text) + + ":" + (.data.line_number | tostring) + + ":" + ((.data.lines.text | if type=="string" then . else "" end) | rtrimstr("\n")) + ) + ' +} diff --git a/lib/ai-search/scope-args.sh b/lib/ai-search/scope-args.sh new file mode 100644 index 0000000..012556d --- /dev/null +++ b/lib/ai-search/scope-args.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# 55-scope-args.sh — case/pattern args, ignore-file resolution, scope filters. +# +# Purpose: translate case_mode/pattern_mode into rg flags (build_case_pattern_args), +# resolve and apply the global gitignore (resolve_global_gitignore / +# apply_global_gitignore), and assemble glob/type/exclude/max-depth scope +# filters (build_rg_scope_args). +# Allowed dependencies: git for the global gitignore lookup. Reads/sets the +# case_args, rg_fixed_args, ignore_args, rg_scope_args globals. +# +# SC2034/SC2154: case/pattern/scope/ignore globals are owned across modules +# (see ai-search.sh load order), not local to this file. +# shellcheck disable=SC2034,SC2154 + +# Directories excluded by default; callers can extend via --exclude. +DEFAULT_EXCLUDES=() +ai_load_config_list DEFAULT_EXCLUDES "$_AI_COMMON_DIR/../share/config/exclude-dirs.txt" vendor node_modules dist build coverage + +# build_case_pattern_args — map case_mode/pattern_mode to rg flag arrays. +build_case_pattern_args() { + # Phase 3C case control. Default is smart-case (case-insensitive unless the + # query contains uppercase), matching rg's native --smart-case. + case_args=() + case "$case_mode" in + ignore) case_args=(--ignore-case) ;; + sensitive) case_args=(--case-sensitive) ;; + smart | *) case_args=(--smart-case) ;; + esac + + # Phase 3C pattern control: literal (--fixed), regex (default/--regex), or PCRE2. + rg_fixed_args=() + case "$pattern_mode" in + fixed) rg_fixed_args=(--fixed-strings) ;; + pcre2) rg_fixed_args=(--pcre2) ;; + *) : ;; + esac + return 0 +} + +# Global gitignore robustness. rg only auto-reads the global gitignore from +# git's GLOBAL/system config (or $XDG_CONFIG_HOME/git/ignore), not a repo-local +# core.excludesfile. To honor the global gitignore deterministically, resolve it +# and pass it via --ignore-file. Skipped when the user disabled global or all +# ignore sources. +resolve_global_gitignore() { + local f + f="$(git config --get core.excludesfile 2>/dev/null || true)" + if [[ -z "$f" ]]; then + f="${XDG_CONFIG_HOME:-$HOME/.config}/git/ignore" + fi + # Expand a leading literal ~ to $HOME. git stores core.excludesfile verbatim, + # so a configured "~/path" arrives as a literal tilde that we must expand + # ourselves. SC2088 warns about tilde-in-quotes, but matching the literal + # prefix is exactly the intent here. + # shellcheck disable=SC2088 + case "$f" in + "~/"*) f="$HOME/${f#"~/"}" ;; + esac + if [[ -f "$f" ]]; then + printf '%s' "$f" + fi + # Always succeed: an absent global gitignore is normal, not an error. The + # trailing `[[ ]] && cmd` footgun under `set -e` would otherwise abort. + return 0 +} + +# apply_global_gitignore — append --ignore-file for the global gitignore unless +# the caller disabled global/all ignore sources. +apply_global_gitignore() { + local _ia ignore_disables_global=0 + for _ia in "${ignore_args[@]+"${ignore_args[@]}"}"; do + [[ "$_ia" == "--no-ignore" || "$_ia" == "--no-ignore-global" ]] && ignore_disables_global=1 + done + if [[ "$ignore_disables_global" -eq 0 ]]; then + global_gitignore="$(resolve_global_gitignore)" + if [[ -n "$global_gitignore" ]]; then + ignore_args+=(--ignore-file "$global_gitignore") + fi + fi + return 0 +} + +# build_rg_scope_args — assemble glob/type/exclude/max-depth filters for content +# searches. Emitted into a global array so multiple backends can reuse it. +build_rg_scope_args() { + rg_scope_args=() + local g t e d + for g in "${glob_args[@]+"${glob_args[@]}"}"; do + rg_scope_args+=(--glob "$g") + done + for t in "${type_args[@]+"${type_args[@]}"}"; do + rg_scope_args+=(--type "$t") + done + for e in "${exclude_args[@]+"${exclude_args[@]}"}"; do + rg_scope_args+=(--glob "!$e" --glob "!$e/**") + done + for d in "${DEFAULT_EXCLUDES[@]}"; do + rg_scope_args+=(--glob "!$d" --glob "!$d/**") + done + if [[ -n "$max_depth" ]]; then + rg_scope_args+=(--max-depth "$max_depth") + fi + return 0 +} diff --git a/lib/ai-search/state.sh b/lib/ai-search/state.sh new file mode 100644 index 0000000..1ff4f90 --- /dev/null +++ b/lib/ai-search/state.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# 20-state.sh — global defaults and per-run state. +# +# Purpose: declare the canonical defaults (DEFAULT_MAX_RESULTS, json_mode, +# warning accumulator) and init_run_state(), which sets every mutable global +# the parser and backends read. Keeping these as plain (non-local) globals is +# intentional: downstream functions assign and read them across boundaries. +# Allowed dependencies: none. Pure variable setup. +# +# SC2034: these globals are read by sibling modules (output, parser, backends, +# dispatch), not within this file, so shellcheck cannot see their use when this +# module is linted standalone. They are intentionally cross-module state. +# shellcheck disable=SC2034 + +DEFAULT_MAX_RESULTS=100 + +json_mode="${AI_OUTPUT:-}" + +# Non-fatal advisories accumulated during a run. +g_warnings=() + +add_warning() { + g_warnings+=("$1") +} + +# init_run_state MODE — initialise all per-run globals from the resolved mode. +# Mirrors the pre-split "Mode dispatch setup" block. These stay global so the +# parser, scope builder, guards, and backends can share them. +init_run_state() { + mode="$1" + g_mode="$mode" + g_query="" + g_max_results="$DEFAULT_MAX_RESULTS" + g_truncated=false + g_results_json="[]" + absolute=0 + context_before=0 + context_after=0 + max_bytes=0 + # Phase 3D count / file-only output. One of: none | files | count | count-matches. + count_mode="none" + g_summary_json="" + # Phase 4 diff/history controls. + diff_staged=0 + diff_base="" + history_messages=0 + history_patch=0 + # Phase 5 structural search language (falls back to AI_LANG, then php). + lang_flag="" + # Phase 3C scope control. + case_mode="smart" # smart | ignore | sensitive + pattern_mode="default" # default | fixed | regex | pcre2 + max_depth="" + glob_args=() + type_args=() + exclude_args=() + # Ignore-file control (rg-backed modes). By DEFAULT all gitignore sources are + # honored: local .gitignore, parent .gitignore, .git/info/exclude, the global + # gitignore (git core.excludesfile), and .ignore/.rgignore files. These flags + # selectively disable those sources to surface otherwise-ignored files. + ignore_args=() + + # Parser scratch globals. + positionals=() + dry_run=0 + # Positional interpretation results. + query="" + root="." + original_mode="$mode" + legacy_alias=0 +} diff --git a/lib/ai-verify/android-guards.sh b/lib/ai-verify/android-guards.sh new file mode 100644 index 0000000..87eee5c --- /dev/null +++ b/lib/ai-verify/android-guards.sh @@ -0,0 +1,127 @@ +# shellcheck shell=bash +# Android/KMP repo-reality guards for the AI verification gate (Kotlin lane). +# +# Part of docs/tickets/arch-todo-android-kmp-verify-lane-20260706-010421 +# (§8-P0-b). This module is intended to be sourced by scripts/ai/ai-verify.sh +# (the thin root loader); it is NOT an entrypoint and must not be executed +# directly. AS OF THIS SLICE (P0) it is NOT YET sourced by ai-verify.sh -- this +# lane must not be wired anywhere yet. Until then it is exercised only by tests +# (see tests/shell/ai-verify-android-guards.bats). +# +# These are the genuinely NET-NEW checks of the Android/KMP proposal (0% overlap +# with any existing check_* -- see plan.md §1c). They target the AdvancedGym +# reality: `:app` + `:shared` are the only active Gradle modules; `composeApp` is +# inactive and its files must not drift; and dependency versions must live in the +# Gradle version catalog, not inline in build scripts. +# +# Contract (mirrors 36-plan-status.sh: check_*): each guard prints an `OK:` or +# `FAIL:` line and increments the GLOBAL $failures on violation, so a caller +# under `set -e` keeps running every guard and tallies failures. $failures must +# be defined by the caller (the root loader does; tests initialise it). +# shellcheck disable=SC2154 # $failures is a global owned by the caller/root loader. +# +# Reuse note (plan.md §1b FLAG-B): check_inactive_compose_app_drift REUSES +# scoped_changed_files_by_pathspec (90-run.sh) when it is defined, instead of +# hand-rolling three git calls. When that engine function is absent (this module +# sourced in isolation, e.g. a direct unit test that does not load 90-run.sh) it +# falls back to a self-contained git form so the guard stays independently +# testable. Both paths honour the same scope semantics for the "changed" scope. + +# Default settings file inspected by the module-activation guards. Overridable +# for tests via GRADLE_SETTINGS_FILE. +GRADLE_SETTINGS_FILE="${GRADLE_SETTINGS_FILE:-settings.gradle.kts}" + +# Hard-fail unless the active Gradle module set is exactly the expected reality: +# `:app` and `:shared` included, and `composeApp` NOT active. A missing settings +# file is itself a failure (the guard cannot prove module reality without it). +check_active_modules() { + echo "==> active Gradle modules" + + if [[ ! -f "$GRADLE_SETTINGS_FILE" ]]; then + echo "FAIL: $GRADLE_SETTINGS_FILE not found; cannot verify active modules" >&2 + failures=$((failures + 1)) + return 0 + fi + + local includes + includes="$(grep -E '^[[:space:]]*include\(' "$GRADLE_SETTINGS_FILE" || true)" + + if ! grep -q '":app"' <<<"$includes"; then + echo "FAIL: :app is not included in $GRADLE_SETTINGS_FILE" >&2 + failures=$((failures + 1)) + fi + + if ! grep -q '":shared"' <<<"$includes"; then + echo "FAIL: :shared is not included in $GRADLE_SETTINGS_FILE" >&2 + failures=$((failures + 1)) + fi + + if grep -q 'composeApp' <<<"$includes"; then + echo "FAIL: composeApp is active in $GRADLE_SETTINGS_FILE but infra reality says inactive" >&2 + failures=$((failures + 1)) + fi +} + +# Print scoped changed files under composeApp/, reusing the pipeline's own +# scope helper when present (FLAG-B), else a self-contained git fallback. +_compose_app_changed_files() { + if declare -F scoped_changed_files_by_pathspec >/dev/null 2>&1; then + # shellcheck disable=SC2154 # AI_VERIFY_SCOPE set by caller/root loader + scoped_changed_files_by_pathspec "${AI_VERIFY_SCOPE:-changed}" 'composeApp/**' 'composeApp' + return 0 + fi + { + git diff --name-only --diff-filter=ACMRT -- composeApp + git diff --cached --name-only --diff-filter=ACMRT -- composeApp + git ls-files --others --exclude-standard -- composeApp + } | sort -u +} + +# Hard-fail when the inactive composeApp module has changed files, unless the +# explicit ALLOW_INACTIVE_MODULE_CHANGES=1 escape hatch is set (for deliberate +# quarantine/reactivation work only). This prevents composeApp edits being +# treated as active-app evidence. +check_inactive_compose_app_drift() { + echo "==> inactive composeApp drift" + + local changed + changed="$(_compose_app_changed_files)" + + if [[ -n "$changed" && "${ALLOW_INACTIVE_MODULE_CHANGES:-0}" != "1" ]]; then + echo "FAIL: composeApp is inactive but has changed files:" >&2 + while IFS= read -r f; do + [[ -n "$f" ]] || continue + echo " $f" >&2 + done <<<"$changed" + echo "Set ALLOW_INACTIVE_MODULE_CHANGES=1 only for explicit quarantine/reactivation work." >&2 + failures=$((failures + 1)) + else + echo "OK: no inactive composeApp drift" + fi +} + +# Hard-fail when a Gradle build script declares an inline, hard-coded dependency +# VERSION (group:name:version) instead of routing through the Gradle version +# catalog. Scans *.gradle.kts / *.gradle outside build/ and .gradle/. This is the +# version-catalog discipline guard from the proposal. +check_version_catalog_required() { + echo "==> version catalog discipline" + + local hits + hits="$( + find . -type f \( -name '*.gradle.kts' -o -name '*.gradle' \) \ + ! -path './build/*' ! -path '*/build/*' ! -path './.gradle/*' -print0 2>/dev/null | + xargs -0 grep -nE 'implementation\("[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+:[0-9]' 2>/dev/null || true + )" + + if [[ -n "$hits" ]]; then + echo "FAIL: direct dependency versions found outside the version catalog:" >&2 + while IFS= read -r line; do + [[ -n "$line" ]] || continue + echo " $line" >&2 + done <<<"$hits" + failures=$((failures + 1)) + else + echo "OK: no direct dependency versions outside the version catalog" + fi +} diff --git a/lib/ai-verify/duplication.sh b/lib/ai-verify/duplication.sh new file mode 100644 index 0000000..53bfd3e --- /dev/null +++ b/lib/ai-verify/duplication.sh @@ -0,0 +1,99 @@ +# shellcheck shell=bash +# Optional jscpd-based code-duplication guardrail for the AI verification gate. +# +# This module is sourced by scripts/ai/ai-verify.sh (the thin root loader); +# it is NOT an entrypoint and must not be executed directly. It is sourced +# AFTER 30-linecount.sh (reuses linecount_scoped_files for the default, +# scope-aware path list) and AFTER 40-step-runner.sh (reuses run_with_timeout +# for the anti-freeze bound around the external jscpd process). +# +# Off by default (VERIFY_JSCPD=0): mirrors the opt-in posture of VERIFY_LINKS +# and VERIFY_SECRETS elsewhere in this pipeline. When enabled, jscpd is only +# fetched via `npx --yes jscpd` if a local `jscpd` binary is not already on +# PATH — never a silent/default network fetch (see docs/ai/approval-boundaries.md). +# +# Tiering mirrors 30-linecount.sh: WARN at JSCPD_WARN_PCT, and a hard +# verification FAILURE (increments $failures) only when JSCPD_FAIL_PCT is +# explicitly set and crossed. Leaving JSCPD_FAIL_PCT empty (the default) makes +# this check advisory-only. +# +# Known limitation, documented honestly rather than silently: jscpd's markdown +# reporter only tokenizes fenced code blocks, not prose, so this check measures +# code/example-fence duplication, not full prose duplication (see +# v0.6-plan/arch-todo-v0-6-shipped-surface-program-20260704-000001/plan.md). +check_jscpd() { + [[ "$VERIFY_JSCPD" == "1" ]] || { + log_warn "Skipping jscpd duplication check. Use VERIFY_JSCPD=1 to enable." + return 0 + } + + echo "==> jscpd" + + local jscpd_cmd=() + if command -v jscpd >/dev/null 2>&1; then + jscpd_cmd=(jscpd) + elif command -v npx >/dev/null 2>&1; then + jscpd_cmd=(npx --yes jscpd) + else + log_warn "jscpd not found and npx unavailable; skipping duplication check." + return 0 + fi + + local paths=() + if [[ -n "$JSCPD_PATHS" ]]; then + # shellcheck disable=SC2206 # intentional word-splitting of a user-set path list + paths=($JSCPD_PATHS) + else + while IFS= read -r f; do + [[ -n "$f" ]] || continue + [[ -f "$f" ]] || continue + paths+=("$f") + done < <(linecount_scoped_files) + # jscpd requires at least one path; fall back to "." when the scoped + # file list is empty (e.g. nothing changed in the current scope). + ((${#paths[@]} > 0)) || paths=(.) + fi + + local report_dir + report_dir="$(mktemp -d 2>/dev/null || printf '/tmp/jscpd-report-%s' "$$")" + # shellcheck disable=SC2064 # intentional early expansion: report_dir is fixed per invocation + trap "rm -rf '$report_dir'" RETURN + + # .gitignore is respected by default (jscpd only exposes --no-gitignore to + # disable that); no explicit flag is needed to enable it. + local rc=0 + run_with_timeout "$VERIFY_TIMEOUT" "${jscpd_cmd[@]}" "${paths[@]}" \ + --reporters json \ + --output "$report_dir" \ + --min-tokens "$JSCPD_MIN_TOKENS" \ + --silent \ + >/dev/null 2>&1 || rc=$? + + local report_file="$report_dir/jscpd-report.json" + if [[ ! -f "$report_file" ]]; then + log_warn "jscpd produced no report (exit $rc); skipping duplication check." + return 0 + fi + + local percentage + percentage="$(jq -r '.statistics.total.percentage // 0' "$report_file" 2>/dev/null || echo 0)" + + local warn_hit=0 fail_hit=0 + if awk -v p="$percentage" -v w="$JSCPD_WARN_PCT" 'BEGIN{exit !(p>=w)}' =f)}' = ${JSCPD_FAIL_PCT}% (fail threshold)" + failures=$((failures + 1)) + log_json "verify.jscpd" "$(jq -cn --argjson pct "$percentage" --argjson fail_threshold "$JSCPD_FAIL_PCT" '{percentage:$pct, fail_threshold:$fail_threshold, status:"fail"}')" || true + elif ((warn_hit)); then + log_warn "jscpd duplication = ${percentage}% >= ${JSCPD_WARN_PCT}% (advisory; set JSCPD_FAIL_PCT to enforce)" + log_json "verify.jscpd" "$(jq -cn --argjson pct "$percentage" --argjson warn_threshold "$JSCPD_WARN_PCT" '{percentage:$pct, warn_threshold:$warn_threshold, status:"warn"}')" || true + else + log_ok "jscpd duplication = ${percentage}% (under ${JSCPD_WARN_PCT}%)" + fi +} diff --git a/lib/ai-verify/gradle-policy.sh b/lib/ai-verify/gradle-policy.sh new file mode 100644 index 0000000..ff9295a --- /dev/null +++ b/lib/ai-verify/gradle-policy.sh @@ -0,0 +1,49 @@ +# shellcheck shell=bash +# Gradle tool-availability policy for the AI verification gate (Kotlin/Android lane). +# +# Part of docs/tickets/arch-todo-android-kmp-verify-lane-20260706-010421 +# (§8-P0-a). This module is intended to be sourced by scripts/ai/ai-verify.sh +# (the thin root loader); it is NOT an entrypoint and must not be executed +# directly. AS OF THIS SLICE (P0) it is NOT YET sourced by ai-verify.sh -- this +# lane must not be wired anywhere yet. Until then it is exercised only by tests +# (see tests/shell/ai-verify-gradle-policy.bats). +# +# FOLD-IN TARGET (plan.md §1b FLAG-D): this MIRRORS the sibling ticket's +# scripts/ai/internal/ai-verify/50-tool-policy.sh conventions (a fixed, documented +# guard with NO PATH fallback for framework-specific tools) rather than editing +# that file, which the concurrent session owns. The intended end state folds a +# `gradle`/`gradlew` case into 50-tool-policy.sh's can_run_tool `*)` arm; a later +# unblocked slice should move these predicates there. +# +# Safety posture (mirrors 50-tool-policy.sh): the ONLY Gradle entrypoint this +# lane ever runs is the project's own committed wrapper (`./gradlew`). It never +# falls back to a PATH-installed `gradle`, because a global Gradle would not +# reflect this project's pinned wrapper/distribution version -- exactly the same +# reasoning as has_composer_bin refusing a PATH copy of a vendored tool. + +# True (exit 0) when this project ships an executable Gradle wrapper at its root. +# Everything in this lane is gated on this: with no ./gradlew, no Gradle task can +# be run safely (per the "already available, project-local only" rule). +has_gradle_wrapper() { + [[ -x ./gradlew ]] +} + +# True (exit 0) when the given Gradle task exists in this project, per the +# project's own wrapper. Uses `./gradlew tasks --all` (the documented Gradle way +# to enumerate available tasks) and matches the task name so a verify script +# never invokes a task that has been renamed/removed (the "verify scripts drift" +# risk called out in the Android/KMP proposal). Returns false (never errors) when +# there is no wrapper, so callers can guard-then-skip cleanly. +# +# The match is intentionally on the task's short name (the segment after the last +# ':'), because `./gradlew tasks --all` lists module-qualified tasks in a +# `:module:task` form whose exact rendering varies by Gradle version; matching the +# short name is the stable, version-tolerant check the proposal settled on. +gradle_task_exists() { + local task="${1:?task required}" + has_gradle_wrapper || return 1 + + local short="${task##*:}" + ./gradlew tasks --all --console=plain --quiet 2>/dev/null | + grep -qE "(^|[[:space:]:])${short}([[:space:]]|$)" +} diff --git a/lib/ai-verify/kotlin-dispatch.sh b/lib/ai-verify/kotlin-dispatch.sh new file mode 100644 index 0000000..d88c14b --- /dev/null +++ b/lib/ai-verify/kotlin-dispatch.sh @@ -0,0 +1,113 @@ +# shellcheck shell=bash +# Kotlin / Android / KMP verification dispatcher for the AI verification gate. +# +# Part of docs/tickets/arch-todo-android-kmp-verify-lane-20260706-010421 +# (§8-P0-b). This module is intended to be sourced by scripts/ai/ai-verify.sh +# (the thin root loader); it is NOT an entrypoint and must not be executed +# directly. AS OF THIS SLICE (P0) it is NOT YET sourced by ai-verify.sh -- this +# lane must not be wired anywhere yet, and there is intentionally NO +# `--language kotlin` flag, NO ai-verify-kotlin.sh wrapper, and NO registry +# entry. Until then it is exercised only by tests +# (see tests/shell/ai-verify-kotlin-dispatch.bats). +# +# FOLD-IN TARGET (plan.md §1b FLAG-C): ai_verify_kotlin mirrors the sibling +# ticket's intended 53-language-dispatch.sh: ai_verify_language contract, so a +# later unblocked slice can register `kotlin` as a sixth language lane. +# +# Reuse (plan.md §1b FLAG-A): this dispatcher does NOT define its own step +# runner. It calls run_step (40-step-runner.sh) -- inheriting the watchdog, +# VERIFY_TIMEOUT, and the global $failures tally -- and gates every Gradle task +# on gradle_task_exists / has_gradle_wrapper (61-gradle-policy.sh). It never runs +# a task the project does not define, and never runs any tool but ./gradlew. +# shellcheck disable=SC2154 # $failures is a global owned by the caller/root loader. +# +# STUB MODE: when AI_KOTLIN_TEST_MODE=1, gradle tasks are not executed; each +# would-be task is announced via a `PLAN:` line instead. This lets the unit +# tests assert exactly which tasks the dispatcher selects (default vs +# VERIFY_FULL vs VERIFY_IOS) without a real Gradle project, mirroring the +# AI_VERIFY_TEST_MODE stub pattern used elsewhere in this pipeline. + +# The P0 default hard gate for the Android/KMP lane. These prove the active +# Android host builds, the shared KMP module's checks pass, Android lint is +# clean, and the shared common/Android source sets still compile. +AI_KOTLIN_DEFAULT_TASKS=( + ':app:assembleDebug' + ':shared:check' + ':app:lintDebug' + ':shared:compileKotlinMetadata' + ':shared:compileDebugKotlinAndroid' +) + +# Full-mode additions (VERIFY_FULL=1): device/instrumented + full KMP test +# matrix. Guarded by existence so a repo without a connected device task or an +# allTests aggregate task skips them cleanly. +AI_KOTLIN_FULL_TASKS=( + ':app:connectedDebugAndroidTest' + ':shared:allTests' +) + +# iOS compile lanes (VERIFY_IOS=1 only; never a default Linux/Windows gate). +AI_KOTLIN_IOS_TASKS=( + ':shared:compileKotlinIosX64' + ':shared:compileKotlinIosSimulatorArm64' +) + +# Run a single Gradle task iff it exists, via run_step; skip cleanly otherwise. +# In stub mode, announce the selection instead of executing. +_kotlin_run_task() { + local task="${1:?task required}" + + if [[ "${AI_KOTLIN_TEST_MODE:-0}" == "1" ]]; then + echo "PLAN: $task" + return 0 + fi + + if gradle_task_exists "$task"; then + run_step "./gradlew $task" ./gradlew "$task" --console=plain + else + log_warn "Skipping missing Gradle task: $task" + fi +} + +# Kotlin/Android/KMP verification lane. Runs the repo-reality guards, then the +# default P0 gate, then optional full/iOS lanes. Honors AI_VERIFY_SCOPE (via the +# guards' scope helper), VERIFY_FULL, VERIFY_IOS, and VERIFY_TIMEOUT (via +# run_step). Mutates the global $failures exactly like every other lane. +ai_verify_kotlin() { + echo "==> kotlin/android verification lane" + + # Repo-reality guards run first: they are cheap and catch module/version + # drift before the expensive Gradle tasks. They only run outside stub mode + # (they inspect real files / git); stub mode is only about task selection. + if [[ "${AI_KOTLIN_TEST_MODE:-0}" != "1" ]]; then + check_active_modules + check_inactive_compose_app_drift + check_version_catalog_required + + if ! has_gradle_wrapper; then + log_warn "No ./gradlew wrapper found; skipping all Gradle tasks in the kotlin lane." + return 0 + fi + fi + + local task + for task in "${AI_KOTLIN_DEFAULT_TASKS[@]}"; do + _kotlin_run_task "$task" + done + + if [[ "${VERIFY_FULL:-0}" == "1" ]]; then + for task in "${AI_KOTLIN_FULL_TASKS[@]}"; do + _kotlin_run_task "$task" + done + else + log_warn "Skipping full Android/KMP test matrix. Use VERIFY_FULL=1." + fi + + if [[ "${VERIFY_IOS:-0}" == "1" ]]; then + for task in "${AI_KOTLIN_IOS_TASKS[@]}"; do + _kotlin_run_task "$task" + done + else + log_warn "Skipping iOS compile lane. Use VERIFY_IOS=1 on macOS." + fi +} diff --git a/lib/ai-verify/kotlin-files.sh b/lib/ai-verify/kotlin-files.sh new file mode 100644 index 0000000..de3fb36 --- /dev/null +++ b/lib/ai-verify/kotlin-files.sh @@ -0,0 +1,80 @@ +# shellcheck shell=bash +# Kotlin / Android / KMP file-discovery helpers for the AI verification gate. +# +# Part of docs/tickets/arch-todo-android-kmp-verify-lane-20260706-010421 +# (§8-P0-a). This module is intended to be sourced by scripts/ai/ai-verify.sh +# (the thin root loader), the same way every other scripts/ai/internal/ai-verify/ +# module is; it is NOT an entrypoint and must not be executed directly. AS OF +# THIS SLICE (P0) it is NOT YET sourced by scripts/ai/ai-verify.sh -- that wiring +# is deferred to a later, separately-approved slice (constraint: this lane must +# not be wired anywhere yet). Until then this file is only exercised directly, by +# sourcing it in tests (see tests/shell/ai-verify-kotlin-files.bats). +# +# FOLD-IN TARGET (plan.md §1b FLAG-C): this file deliberately MIRRORS the +# sibling ticket's scripts/ai/internal/ai-verify/51-language-files.sh contract +# (language_pathspecs / scoped_language_files) rather than editing it, because +# the concurrent session owns 51-language-files.sh and this lane must not touch +# PHP/JS-lane files. The intended end state is a single `kotlin)` case inside +# 51-language-files.sh's language_pathspecs; a later unblocked slice should move +# kotlin_language_pathspecs' body there and drop this file's discovery half. +# +# Load-order dependency (read before wiring this into ai-verify.sh): +# `scoped_kotlin_files` calls `scoped_changed_files_by_pathspec`, which is +# defined in scripts/ai/internal/ai-verify/90-run.sh. Bash resolves function +# bodies at CALL time, not at source time, so this file may be sourced BEFORE +# 90-run.sh without error -- the dependency only has to be satisfied by the time +# `scoped_kotlin_files` is actually invoked. This mirrors the identical note in +# 51-language-files.sh. +# +# This module also depends on `die` (scripts/ai/internal/lib/05-core.sh, loaded +# transitively via scripts/ai/common.sh) and on `$AI_VERIFY_SCOPE` being set by +# the caller (the root loader sets a default; direct callers/tests must set it +# themselves, e.g. AI_VERIFY_SCOPE=changed). + +# Print one git pathspec glob per line for the Kotlin/Android/KMP surface. This +# covers Kotlin sources (`*.kt`), Kotlin/Gradle scripts (`*.kts`, including +# `*.gradle.kts`), Groovy Gradle scripts (`*.gradle`), and the Gradle version +# catalog (`gradle/libs.versions.toml`) -- the files whose changes should drive a +# Kotlin/Android verification run. Unknown languages are a caller programming +# error (mirrors 51-language-files.sh: language_pathspecs), so this dies loudly. +kotlin_language_pathspecs() { + local lang="${1:?language required}" + + case "$lang" in + kotlin) + printf '%s\n' \ + '*.kt' \ + '*.kts' \ + '*.gradle.kts' \ + '*.gradle' \ + 'gradle/libs.versions.toml' + ;; + *) + die "unknown language: $lang" + ;; + esac +} + +# Emit existing, scoped, changed Kotlin/Gradle files, honoring $AI_VERIFY_SCOPE +# exactly the way the rest of this pipeline does. This does NOT reimplement scope +# resolution: it calls the existing scoped_changed_files_by_pathspec +# (scripts/ai/internal/ai-verify/90-run.sh) once per pathspec returned by +# kotlin_language_pathspecs, merges every pathspec's results, de-duplicates, and +# filters down to paths that currently exist as regular files. Mirrors +# 51-language-files.sh: scoped_language_files. +scoped_kotlin_files() { + local lang="${1:-kotlin}" + local pathspec + + while IFS= read -r pathspec; do + [[ -n "$pathspec" ]] || continue + # shellcheck disable=SC2154 # AI_VERIFY_SCOPE is set by the caller/root loader + scoped_changed_files_by_pathspec "$AI_VERIFY_SCOPE" "$pathspec" + done < <(kotlin_language_pathspecs "$lang") | + sort -u | + while IFS= read -r f; do + [[ -n "$f" ]] || continue + [[ -f "$f" ]] || continue + printf '%s\n' "$f" + done +} diff --git a/lib/ai-verify/language-dispatch.sh b/lib/ai-verify/language-dispatch.sh new file mode 100644 index 0000000..5b246d0 --- /dev/null +++ b/lib/ai-verify/language-dispatch.sh @@ -0,0 +1,315 @@ +# shellcheck shell=bash +# Per-language dispatcher for the AI verification gate. +# +# Part of docs/tickets/arch-todo-safe-language-verify-scripts-20260706-003959. +# This module is sourced by scripts/ai/ai-verify.sh (the thin root loader); it +# is NOT an entrypoint and must not be executed directly. It must be sourced +# AFTER 40-step-runner.sh (run_step/run_step_js/has_package_script/ +# has_package_dependency), 50-tool-policy.sh (can_run_tool), 51-language-files.sh +# (scoped_language_files), 54-reporting.sh (write_verify_report_file), and +# 90-run.sh (scoped_language_files calls scoped_changed_files_by_pathspec, and +# this file calls scope_has_exact_changed_path, both defined there, at CALL +# time). The root loader's ai_verify_language "$AI_VERIFY_LANGUAGE" call happens +# after this file is sourced, so every dependency is already resolvable by then. +# +# §8-P2 shipped `check` mode only. §8-P4/P5 (this revision) add the first +# non-check-mode behavior: +# - `AI_VERIFY_MODE=suggest` (default remains `check`, unchanged behavior): +# JS/TS/Vue eslint diagnostics switch to a non-mutating, ADVISORY-ONLY +# `--fix-dry-run --format json` run (run_eslint_suggest_files) that never +# increments $failures. `fix` mode is still NOT implemented anywhere in +# this file (fix mutates files and needs its own separately-approved +# slice; see plan.md §5 Things To Avoid). +# - `VERIFY_OUTPUT_FORMAT=json` (default `table`, i.e. unchanged plain +# stdout): phpstan/psalm additionally get `--error-format=json`/ +# `--output-format=json` and write a raw-output report file. +# - Rector (`process --dry-run` only, never bare `process`/apply) and +# scoped `composer validate --strict`/`composer audit` join the PHP path. +# - `vitest run --changed` joins the shared JS/TS/Vue lint dispatch as a +# lightweight default (non-VERIFY_FULL) check. +# +# Only five languages are dispatched (php, js, ts, vue, html) via small, +# hand-written per-language runner functions -- this deliberately does NOT +# build a generic metadata-driven tool-adapter framework (plan.md §1b reject +# list); each runner is a short, readable list of `can_run_tool`/ +# `has_package_*` guards mirroring the equivalent inline checks already in +# 90-run.sh, reused via can_run_tool/run_step/run_step_js rather than +# reimplemented. + +# Shared eslint/biome lint dispatch for both JS and TS file sets (and reused by +# the Vue dispatch too). Kept as one function so the three callers +# (run_js_language_files, run_ts_language_files, run_vue_language_files) never +# duplicate the same conditionals (repo `>=75%` reuse rule). This is also +# where `vitest run --changed` is added (§8-P5): all three language dispatch +# functions funnel through here, so adding it once naturally covers js/ts/vue +# without a third copy of the same `has_package_dependency` guard. +run_js_or_ts_lint_files() { + local files=("$@") + local mode="${AI_VERIFY_MODE:-check}" + + if has_package_script lint; then + run_step_js 'pnpm run lint' pnpm run lint + elif [[ "$mode" == "suggest" ]] && can_run_tool eslint; then + run_eslint_suggest_files "${files[@]}" + elif can_run_tool eslint; then + run_step_js "eslint (${#files[@]} file(s))" pnpm exec eslint "${files[@]}" + fi + + if can_run_tool biome; then + run_step_js "biome check (${#files[@]} file(s))" pnpm exec biome check "${files[@]}" + fi + + # Lightweight, default (non-VERIFY_FULL) check: only affected tests for + # changed source, distinct from the full unscoped `vitest run` (deferred + # to §8-P6, gated behind VERIFY_FULL in 90-run.sh, not here). + if has_package_dependency vitest; then + run_step_js 'pnpm exec vitest run --changed' pnpm exec vitest run --changed + fi +} + +# ADVISORY-ONLY diagnostic run for AI_VERIFY_MODE=suggest: reports eslint's +# --fix-dry-run findings as JSON without ever mutating a file and, critically, +# without ever incrementing $failures -- suggest mode only reports, it never +# fails the run (unlike run_step/run_step_js, which always tally a non-zero +# exit as a failure). This is why this path is NOT routed through run_step: +# output is captured directly with `|| true`-equivalent handling of $rc, but +# still runs under the same VERIFY_TIMEOUT anti-freeze bound as every other +# external-process invocation in this pipeline, mirroring the established +# "capture output, never hard-fail" convention in check_jscpd (35-jscpd.sh). +run_eslint_suggest_files() { + local files=("$@") + + echo "==> eslint --fix-dry-run (suggest mode, ${#files[@]} file(s))" + + local output rc=0 + output="$(run_with_timeout "$VERIFY_TIMEOUT" pnpm exec eslint --fix-dry-run --format json "${files[@]}" 2>&1)" || rc=$? + + write_verify_report_file eslint-suggest json "$output" >/dev/null + log_json "verify.eslint_suggest" "$(jq -cn --argjson exit_code "$rc" '{mode:"suggest",exit_code:$exit_code}')" || true + + if ((rc != 0)); then + log_warn "eslint --fix-dry-run (suggest mode) reported findings (exit $rc); advisory only, not counted as a failure." + else + log_ok "eslint --fix-dry-run (suggest mode): no findings." + fi +} + +run_php_language_files() { + local files=("$@") + + # composer validate/audit is evaluated independently of the per-file php + # list below and is placed BEFORE the empty-files early return: + # composer.json/composer.lock can change in scope even when no *.php file + # does (e.g. a dependency bump with no source edits), and this check must + # still fire in that case. Mirrors 90-run.sh's existing changed/branch-scope + # gate (90-run.sh:141-154) by calling the SAME scope_has_exact_changed_path + # function defined there -- the change-detection logic itself is never + # reimplemented here, only reused. Same "ai"->"changed" translation as + # ai_verify_language's $lang_scope above (this per-language dispatch always + # treats the default "ai" scope as scoped/changed-like, unlike 90-run.sh's + # own top-level pipeline where "ai" runs composer checks unconditionally). + local composer_scope="$AI_VERIFY_SCOPE" + if [[ "$composer_scope" == "ai" ]]; then + composer_scope="changed" + fi + if scope_has_exact_changed_path "$composer_scope" composer.json composer.lock && + command -v composer >/dev/null 2>&1; then + run_step 'composer validate --strict' composer validate --strict + run_step 'composer audit' composer audit + fi + + if ((${#files[@]} == 0)); then + log_warn "No changed PHP files in scope ($AI_VERIFY_SCOPE); skipping PHP checks." + return 0 + fi + + if can_run_tool pint; then + run_step "pint check (${#files[@]} file(s))" vendor/bin/pint --test "${files[@]}" + fi + + # VERIFY_OUTPUT_FORMAT (default "table", i.e. today's unchanged plain + # stdout behavior). Chosen mechanics, documented since exact behavior + # matters for tests: run_step ALWAYS performs the real gating invocation + # exactly as before (same watchdog/streaming/failure-tally behavior -- + # only the appended --error-format=json/--output-format=json flag differs + # in json mode). In json mode a SEPARATE, second, non-gating invocation of + # the SAME command additionally captures combined stdout+stderr into a + # string written verbatim to the per-tool report file via + # write_verify_report_file (54-reporting.sh). This follow-up-capture + # approach is safe here specifically because phpstan analyse / psalm are + # read-only, idempotent static analysers that never mutate source, so + # running the same analysis twice changes no file and no exit-code-driven + # behavior -- this is NOT a pattern to copy for any mutating command. The + # second run is intentionally NOT routed through run_step so it can never + # double-count $failures. + local output_format="${VERIFY_OUTPUT_FORMAT:-table}" + + if can_run_tool phpstan; then + local phpstan_cmd=(vendor/bin/phpstan analyse --memory-limit=1G) + [[ "$output_format" == "json" ]] && phpstan_cmd+=(--error-format=json) + run_step "phpstan (${#files[@]} file(s))" "${phpstan_cmd[@]}" "${files[@]}" + if [[ "$output_format" == "json" ]]; then + local phpstan_out + phpstan_out="$("${phpstan_cmd[@]}" "${files[@]}" 2>&1)" || true + write_verify_report_file phpstan json "$phpstan_out" >/dev/null + fi + fi + + if can_run_tool psalm; then + local psalm_cmd=(vendor/bin/psalm --no-cache) + [[ "$output_format" == "json" ]] && psalm_cmd+=(--output-format=json) + run_step "psalm (${#files[@]} file(s))" "${psalm_cmd[@]}" "${files[@]}" + if [[ "$output_format" == "json" ]]; then + local psalm_out + psalm_out="$("${psalm_cmd[@]}" "${files[@]}" 2>&1)" || true + write_verify_report_file psalm json "$psalm_out" >/dev/null + fi + fi + + # Non-mutating: `process --dry-run` only, NEVER bare `rector process` + # (which would rewrite files in place). See plan.md §5 Things To Avoid. + if can_run_tool rector; then + run_step "rector dry-run (${#files[@]} file(s))" vendor/bin/rector process --dry-run "${files[@]}" + fi +} + +run_js_language_files() { + local files=("$@") + + if ((${#files[@]} == 0)); then + log_warn "No changed JS files in scope ($AI_VERIFY_SCOPE); skipping JS checks." + return 0 + fi + + run_js_or_ts_lint_files "${files[@]}" + + # knip is project-wide by nature (it analyses the whole dependency graph), + # so it is never passed a file list -- matches the equivalent inline check + # in 90-run.sh:301-303. + if can_run_tool knip; then + run_step_js 'pnpm exec knip' pnpm exec knip + fi +} + +run_ts_language_files() { + local files=("$@") + + if ((${#files[@]} == 0)); then + log_warn "No changed TS files in scope ($AI_VERIFY_SCOPE); skipping TS checks." + return 0 + fi + + # tsc --noEmit is inherently project-wide (it resolves the whole + # tsconfig.json project graph), so it is never passed a file list -- + # matches the equivalent inline check in 90-run.sh:268-272. + if has_package_script typecheck; then + run_step_js 'pnpm run typecheck' pnpm run typecheck + elif [[ -f tsconfig.json ]] && has_package_dependency typescript; then + run_step_js 'pnpm exec tsc --noEmit' pnpm exec tsc --noEmit + fi + + run_js_or_ts_lint_files "${files[@]}" +} + +run_vue_language_files() { + local files=("$@") + + if ((${#files[@]} == 0)); then + log_warn "No changed Vue files in scope ($AI_VERIFY_SCOPE); skipping Vue checks." + return 0 + fi + + run_js_or_ts_lint_files "${files[@]}" + + # vue-tsc/nuxi typecheck are project-wide, not file-scoped -- matches the + # equivalent inline checks in 90-run.sh:274-280. + if can_run_tool vue-tsc; then + run_step_js 'pnpm exec vue-tsc --noEmit' pnpm exec vue-tsc --noEmit + fi + + if can_run_tool nuxt; then + run_step_js 'pnpm exec nuxi typecheck' pnpm exec nuxi typecheck + fi +} + +# Detection order (fixed, per plan.md Q3): biome -> htmlhint -> clean skip. +# There is no safe "configured eslint" fallback wired here yet (HTML-aware +# eslint configs vary too much to guess safely); biome/htmlhint cover the +# common case and anything else cleanly skips with a log_warn, never a +# failure. +run_html_language_files() { + local files=("$@") + + if ((${#files[@]} == 0)); then + log_warn "No changed HTML files in scope ($AI_VERIFY_SCOPE); skipping HTML checks." + return 0 + fi + + if can_run_tool biome; then + run_step_js "biome check (${#files[@]} file(s))" pnpm exec biome check "${files[@]}" + elif has_package_dependency htmlhint; then + run_step_js "htmlhint (${#files[@]} file(s))" pnpm exec htmlhint "${files[@]}" + else + log_warn "No configured HTML verifier found (biome/htmlhint); skipping HTML." + fi +} + +# Entry point called by the root loader when --language is passed. +# Runs ONLY that language's check subset and exits 0/1 based on the shared +# $failures tally, mirroring ai_verify_run's own exit contract (90-run.sh). +ai_verify_language() { + local lang="${1:?language required}" + + case "$lang" in + php | js | ts | vue | html) ;; + *) + die "unknown language: $lang" + ;; + esac + + # CRITICAL: AI_VERIFY_SCOPE defaults to "ai" (scripts/ai/ai-verify.sh), but + # scoped_changed_files_by_pathspec (90-run.sh) only has case arms for + # "branch" and "changed" -- its default arm (`*) return 0`) silently + # returns ZERO files for scope "ai". Translate ai -> changed before calling + # scoped_language_files, exactly like 90-run.sh's own PHP-scoping + # workaround (php_scope_source="$AI_VERIFY_SCOPE"; ai) php_scope_source= + # "changed" ;; ...; AI_VERIFY_SCOPE="$php_scope_source" scoped_php_files). + # Without this translation, `bash scripts/ai/ai-verify-php.sh .` run with + # no env vars set (the common case) would silently find zero files and + # report everything skipped. + local lang_scope="$AI_VERIFY_SCOPE" + if [[ "$lang_scope" == "ai" ]]; then + lang_scope="changed" + fi + + local files=() + while IFS= read -r f; do + [[ -n "$f" ]] && files+=("$f") + done < <(AI_VERIFY_SCOPE="$lang_scope" scoped_language_files "$lang") + + echo "==> language:$lang" + + case "$lang" in + php) + run_php_language_files "${files[@]}" + ;; + js) + run_js_language_files "${files[@]}" + ;; + ts) + run_ts_language_files "${files[@]}" + ;; + vue) + run_vue_language_files "${files[@]}" + ;; + html) + run_html_language_files "${files[@]}" + ;; + esac + + echo "==> done" + + # shellcheck disable=SC2154 # $failures is the root loader's global tally + ((failures > 0)) && exit 1 + exit 0 +} diff --git a/lib/ai-verify/language-files.sh b/lib/ai-verify/language-files.sh new file mode 100644 index 0000000..1f8a146 --- /dev/null +++ b/lib/ai-verify/language-files.sh @@ -0,0 +1,84 @@ +# shellcheck shell=bash +# Per-language file-discovery helpers for the AI verification gate. +# +# Part of docs/tickets/arch-todo-safe-language-verify-scripts-20260706-003959 +# (§8-P1). This module is intended to be sourced by scripts/ai/ai-verify.sh +# (the thin root loader), the same way every other scripts/ai/internal/ai-verify/ +# module is; it is NOT an entrypoint and must not be executed directly. AS OF +# THIS SLICE (P1) it is NOT YET sourced by scripts/ai/ai-verify.sh — that wiring +# is deferred to a later slice (P2, per-language dispatcher + `--language` flag). +# Until then this file is only exercised directly, by sourcing it in tests +# (see tests/shell/ai-verify-language-files.bats) or from a future standalone +# per-language wrapper script. +# +# Load-order dependency (read before wiring this into ai-verify.sh): +# `scoped_language_files` calls `scoped_changed_files_by_pathspec`, which is +# defined in scripts/ai/internal/ai-verify/90-run.sh. Bash resolves function +# bodies at CALL time, not at source time, so this file may be sourced BEFORE +# 90-run.sh without error -- the dependency only has to be satisfied by the time +# `scoped_language_files` is actually invoked, not by the time this file is +# read. Today's root loader (scripts/ai/ai-verify.sh) sources modules in this +# order: 20-shipped-filters, 10-scope, 30-linecount, 40-step-runner, 35-jscpd, +# 36-plan-status, 90-run (last). The P2 implementer deciding where to source +# this 51-* file only needs to ensure it happens sometime before +# `scoped_language_files` is first CALLED (typically from a not-yet-built +# 53-language-dispatch.sh); it does not need to load before 90-run.sh sources. +# +# This module also depends on `die` (scripts/ai/internal/lib/05-core.sh, loaded +# transitively via scripts/ai/common.sh) and on `$AI_VERIFY_SCOPE` being set by +# the caller (the root loader sets a default of "ai"; direct callers/tests must +# set it themselves, e.g. AI_VERIFY_SCOPE=changed). + +# Print one git pathspec glob per line for the given language. Unknown +# languages are a caller programming error, not a soft-skip condition, so this +# dies loudly (mirrors the "unknown AI_VERIFY_SCOPE" die() calls elsewhere in +# this pipeline, e.g. scripts/ai/internal/ai-verify/10-scope.sh). +language_pathspecs() { + local lang="${1:?language required}" + + case "$lang" in + php) + printf '%s\n' '*.php' + ;; + js) + printf '%s\n' '*.js' '*.jsx' '*.mjs' '*.cjs' + ;; + ts) + printf '%s\n' '*.ts' '*.tsx' '*.mts' '*.cts' + ;; + vue) + printf '%s\n' '*.vue' + ;; + html) + printf '%s\n' '*.html' '*.blade.php' '*.twig' + ;; + *) + die "unknown language: $lang" + ;; + esac +} + +# Emit existing, scoped, changed files for the given language, honoring +# $AI_VERIFY_SCOPE exactly the way the rest of this pipeline does. This does +# NOT reimplement scope resolution: it calls the existing +# scoped_changed_files_by_pathspec (scripts/ai/internal/ai-verify/90-run.sh) once +# per pathspec returned by language_pathspecs, merges every pathspec's results, +# de-duplicates, and filters down to paths that currently exist as regular +# files (a path can appear in a diff/ls-files listing and then be deleted +# before this check runs, or be a submodule/symlink entry rather than a file). +scoped_language_files() { + local lang="${1:?language required}" + local pathspec + + while IFS= read -r pathspec; do + [[ -n "$pathspec" ]] || continue + # shellcheck disable=SC2154 # AI_VERIFY_SCOPE is set by the caller/root loader + scoped_changed_files_by_pathspec "$AI_VERIFY_SCOPE" "$pathspec" + done < <(language_pathspecs "$lang") | + sort -u | + while IFS= read -r f; do + [[ -n "$f" ]] || continue + [[ -f "$f" ]] || continue + printf '%s\n' "$f" + done +} diff --git a/lib/ai-verify/line-count.sh b/lib/ai-verify/line-count.sh new file mode 100644 index 0000000..9ba6d53 --- /dev/null +++ b/lib/ai-verify/line-count.sh @@ -0,0 +1,58 @@ +# shellcheck shell=bash +# File line-count guardrail for the AI verification gate. +# +# This module is sourced by scripts/ai/ai-verify.sh (the thin root loader); +# it is NOT an entrypoint and must not be executed directly. It is sourced +# AFTER common.sh (for log_* / log_json) and AFTER 10-scope.sh (for +# linecount_scoped_files). It mutates the global $failures tally on hard errors. +# +# Behavior is byte-for-byte identical to the previous monolithic ai-verify.sh; +# only the file layout changed. + +# Tiered file line-count guardrail. Counts lines per in-scope file and reports: +# >= LINECOUNT_INFO -> info (heads-up) +# >= LINECOUNT_WARN -> warn (should refactor soon) +# >= LINECOUNT_ERROR -> error (urgent refactor; counts as a verification failure) +# Each file is reported at its highest matching tier only. Files that exceed the +# error threshold increment the global failure tally so ai-verify exits non-zero. +check_line_counts() { + [[ "$VERIFY_LINECOUNT" == "1" ]] || { + log_warn "Skipping line-count check. Use VERIFY_LINECOUNT=1 to enable." + return 0 + } + + echo "==> line-count" + + local file lines errors=0 flagged=0 + while IFS= read -r file; do + [[ -n "$file" ]] || continue + [[ -f "$file" ]] || continue + # Skip binary files: grep -Iq prints nothing and returns 1 for binaries. + grep -Iq . "$file" 2>/dev/null || continue + + lines="$(wc -l <"$file" 2>/dev/null | tr -d ' ')" + [[ "$lines" =~ ^[0-9]+$ ]] || continue + + if ((lines >= LINECOUNT_ERROR)); then + log_error "line-count $file = $lines lines >= $LINECOUNT_ERROR (URGENT refactor needed)" + errors=$((errors + 1)) + flagged=$((flagged + 1)) + elif ((lines >= LINECOUNT_WARN)); then + log_warn "line-count $file = $lines lines >= $LINECOUNT_WARN (refactor recommended)" + flagged=$((flagged + 1)) + elif ((lines >= LINECOUNT_INFO)); then + log_info "line-count $file = $lines lines >= $LINECOUNT_INFO (getting large)" + flagged=$((flagged + 1)) + fi + done < <(linecount_scoped_files) + + if ((errors > 0)); then + echo "FAIL: line-count $errors file(s) >= $LINECOUNT_ERROR lines (urgent refactor)" >&2 + failures=$((failures + errors)) + log_json "verify.linecount" "$(jq -cn --argjson errors "$errors" --argjson flagged "$flagged" \ + --argjson error_threshold "$LINECOUNT_ERROR" \ + '{errors:$errors, flagged:$flagged, error_threshold:$error_threshold}')" || true + elif ((flagged == 0)); then + log_ok "line-count: all in-scope files under $LINECOUNT_INFO lines" + fi +} diff --git a/lib/ai-verify/plan-status.sh b/lib/ai-verify/plan-status.sh new file mode 100644 index 0000000..7dbc9ad --- /dev/null +++ b/lib/ai-verify/plan-status.sh @@ -0,0 +1,155 @@ +# shellcheck shell=bash +# Todo-plan checklist status guardrail for the AI verification gate. +# +# This module is sourced by scripts/ai/ai-verify.sh (the thin root loader); +# it is NOT an entrypoint and must not be executed directly. It is sourced +# AFTER common.sh (for log_* / log_json) and AFTER 10-scope.sh (for +# linecount_scoped_files, reused here for the same scope-aware file list). +# +# Purpose: architecture-plan-writer and implementer both work against Todo +# markdown plans under docs/tickets/**/plan*.md (`- [ ]` / `- [x]` checklist +# items in `## Todo Plan` and `## Acceptance Criteria`). This check scans any +# such plan file that is in scope and reports: +# - OK when every checklist item in the file is checked (`- [x]`) +# - WARN when some items are still unchecked (`- [ ]`) — incomplete, not a +# hard failure, since a plan is normally read/updated across many sessions +# - ERROR (hard verification failure, increments $failures) when a checklist +# item itself (a `- [ ]`/`- [x]` line, not the surrounding prose) contains +# a difficulty-notice phrase (see PLAN_STATUS_DIFFICULTY_PATTERN) such as +# "impossible", "not enough context", or "missing " +# — these mark a Todo/AC item an agent could not carry out as written and +# that a human should look at. Scanning is deliberately limited to +# checklist-item lines: scanning the whole file would also match ordinary, +# benign prose (e.g. a plan's own `Status: ... not implemented` header, +# which just means "not started yet", not "in difficulty"). +# +# This intentionally does not require a new top-level scripts/ai/*.sh +# entrypoint or any agent permission change: implementer already has +# permission to invoke ai-verify.sh (see .opencode/agents/implementer.md), so +# folding the check in here is enough to "wire it into implementer" per +# docs/tickets discussion. architecture-plan-writer is not wired directly in +# this slice (its bash allowlist stays minimal by design); extending that is a +# separate, larger permission decision. +# +# Off-by-default posture is NOT used here (unlike VERIFY_JSCPD/VERIFY_LINKS): +# this check needs no external tool, only grep/awk over already-scoped text +# files, so it is cheap enough to default on like VERIFY_LINECOUNT. + +# Default docs/tickets plan-file glob, scoped to top-level ticket folders' +# plan.md / plan-N-desc.md files. Archived (already-complete) plans under any +# archive/ subfolder are intentionally excluded — they are done by definition. +# +# Deliberately does NOT include a bare "not implemented" — that phrase alone is +# the normal, expected description of any not-yet-started Todo/AC item and +# would false-positive on nearly every fresh plan. The narrower "cannot ... be +# implemented" / "unable to implement" forms below capture the genuine +# difficulty case (an item the agent tried and could not carry out). +PLAN_STATUS_DIFFICULTY_PATTERN="${PLAN_STATUS_DIFFICULTY_PATTERN:-impossible|not enough context|missing (context|information|details?|requirements?|decision|owner|approval)|insufficient (context|information|detail)|cannot be (done|completed|implemented)|unable to (implement|complete|proceed)|no clear (owner|path forward)|blocked by unknown}" + +# True when $1 is an in-scope Todo plan file this check should inspect: +# docs/tickets//plan*.md, excluding anything under an archive/ dir. +is_plan_status_target() { + case "$1" in + docs/tickets/*/archive/* | */archive/*) return 1 ;; + docs/tickets/*/plan*.md) return 0 ;; + esac + return 1 +} + +# Print "checkedunchecked" counts of top-level Markdown task items +# (`- [ ]` / `- [x]`, optionally indented) in the given file. +plan_status_checklist_counts() { + local file="$1" + awk ' + match($0, /^[[:space:]]*-[[:space:]]+\[[xX ]\]/) { + box = substr($0, RSTART, RLENGTH) + if (box ~ /\[[xX]\]$/) { checked++ } else { unchecked++ } + } + END { printf "%d\t%d\n", checked + 0, unchecked + 0 } + ' "$file" +} + +# Print "line: text" for each *checklist-item* line (`- [ ]`/`- [x]`, optionally +# indented) that also matches PLAN_STATUS_DIFFICULTY_PATTERN (case-insensitive), +# used as evidence in the ERROR report. Only checklist-item lines are scanned — +# see the header comment for why the rest of the file's prose is out of scope. +plan_status_difficulty_hits() { + local file="$1" + awk -v pat="$PLAN_STATUS_DIFFICULTY_PATTERN" ' + match($0, /^[[:space:]]*-[[:space:]]+\[[xX ]\]/) { + if (tolower($0) ~ pat) { print NR ": " $0 } + } + ' "$file" +} + +check_plan_status() { + [[ "$VERIFY_PLAN_STATUS" == "1" ]] || { + log_warn "Skipping plan Todo-status check. Use VERIFY_PLAN_STATUS=1 to enable." + return 0 + } + + echo "==> plan-status" + + local -a plan_files=() + while IFS= read -r f; do + [[ -n "$f" ]] || continue + [[ -f "$f" ]] || continue + is_plan_status_target "$f" || continue + plan_files+=("$f") + done < <(linecount_scoped_files) + + if ((${#plan_files[@]} == 0)); then + log_ok "plan-status: no in-scope docs/tickets/**/plan*.md files" + return 0 + fi + + local file counts checked unchecked hits + local files_error=0 files_warn=0 files_ok=0 + + for file in "${plan_files[@]}"; do + # `|| true`: grep exits non-zero on no match, which under this script's + # `set -e` would otherwise abort the whole check on the (common) case + # of a clean file with no difficulty notices. + hits="$(plan_status_difficulty_hits "$file" || true)" + if [[ -n "$hits" ]]; then + log_error "plan-status $file: difficulty notice found (blocks completion):" + while IFS= read -r hit; do + [[ -n "$hit" ]] || continue + echo " $hit" >&2 + done <<<"$hits" + files_error=$((files_error + 1)) + continue + fi + + counts="$(plan_status_checklist_counts "$file")" + checked="${counts%%$'\t'*}" + unchecked="${counts##*$'\t'}" + + if ((checked == 0 && unchecked == 0)); then + log_warn "plan-status $file: no \`- [ ]\`/\`- [x]\` checklist items found" + continue + elif ((unchecked > 0)); then + log_warn "plan-status $file: $unchecked incomplete / $checked complete Todo item(s)" + files_warn=$((files_warn + 1)) + else + log_ok "plan-status $file: all $checked Todo item(s) complete" + files_ok=$((files_ok + 1)) + fi + done + + if ((files_error > 0)); then + echo "FAIL: plan-status $files_error plan file(s) flagged with difficulty notices" >&2 + failures=$((failures + files_error)) + elif ((files_warn > 0)); then + log_warn "plan-status: $files_warn plan file(s) still have incomplete Todo items" + elif ((files_ok > 0)); then + log_ok "plan-status: all ${#plan_files[@]} in-scope plan file(s) fully complete" + fi + + log_json "verify.plan_status" "$(jq -cn \ + --argjson total "${#plan_files[@]}" \ + --argjson ok "$files_ok" \ + --argjson warn "$files_warn" \ + --argjson error "$files_error" \ + '{total:$total, ok:$ok, warn:$warn, error:$error}')" || true +} diff --git a/lib/ai-verify/reporting.sh b/lib/ai-verify/reporting.sh new file mode 100644 index 0000000..6865c53 --- /dev/null +++ b/lib/ai-verify/reporting.sh @@ -0,0 +1,72 @@ +# shellcheck shell=bash +# Per-tool verification report-file helpers for the AI verification gate. +# +# Part of docs/tickets/arch-todo-safe-language-verify-scripts-20260706-003959 +# (§8-P1). This module is intended to be sourced by scripts/ai/ai-verify.sh +# (the thin root loader), the same way every other scripts/ai/internal/ai-verify/ +# module is; it is NOT an entrypoint and must not be executed directly. AS OF +# THIS SLICE (P1) it is NOT YET sourced by scripts/ai/ai-verify.sh — that wiring +# is deferred to a later slice (P2/P3). Until then this file is only exercised +# directly, by sourcing it in tests (see tests/shell/ai-verify-reporting.bats). +# +# Dependencies: only scripts/ai/common.sh (for $AI_LOG_DIR, defaulted in +# scripts/ai/internal/lib/00-env.sh). No dependency on any other +# internal/ai-verify/*.sh module, so this file's eventual source position in +# ai-verify.sh's load order is unconstrained relative to the other numbered +# modules. +# +# FLAG-1 (plan.md §1b): this repo's canonical evidence root is $AI_LOG_DIR +# (default .ai-logs/). An externally reviewed suggestion for this feature +# proposed a competing, hardcoded top-level report directory instead (a +# dot-ai-prefixed directory with its own "verify" subfolder, distinct from +# $AI_LOG_DIR) -- that would introduce a second, un-gitignored evidence root and +# is explicitly rejected here. VERIFY_REPORT_DIR therefore defaults FROM +# $AI_LOG_DIR below, so a caller who only overrides AI_LOG_DIR still gets a +# consistent, single evidence root without needing a second override. No +# hardcoded evidence-directory literal other than the $AI_LOG_DIR-derived +# default may appear in this file (enforced by +# tests/shell/ai-verify-reporting.bats). +# +# FLAG-2 (plan.md §1b): structured *events* already have a home: log_json() +# in scripts/ai/internal/lib/30-logging.sh writes a rich, versioned JSONL +# envelope (trace/session/repo/git context) to ${AI_LOG_DIR}/tool-usage.jsonl, +# and is already used by this pipeline (see 90-run.sh, 35-jscpd.sh, +# 30-linecount.sh). This module deliberately does NOT add a +# `write_verify_event` function or a competing events.jsonl writer — that would +# be a near-total reimplementation of log_json (>=75% overlap; see AGENTS.md +# reuse rule). Callers that need a structured verify event should call +# log_json directly, e.g.: +# log_json "verify.tool.ran" "$(jq -cn --arg tool "$name" '{tool:$tool}')" +# No thin wrapper is added around log_json here: the call above is already a +# single, clear line, so an extra indirection layer would not improve +# call-site clarity and would only add a second name for the same thing. +# +# The one genuinely new need this module fills is writing a tool's raw +# (non-JSONL) report output -- e.g. eslint's own --format json output, or +# phpstan's plain-text output -- to a per-tool file on disk. + +# Resolve (and ensure) the directory verification report files are written +# under. Defaults from $AI_LOG_DIR (never a second, hardcoded evidence root) so +# reports always live beside this repo's other local evidence. Prints the +# resolved, created directory path. +verify_report_dir() { + local dir="${VERIFY_REPORT_DIR:-${AI_LOG_DIR:-.ai-logs}/verify}" + mkdir -p "$dir" + printf '%s\n' "$dir" +} + +# Write $content verbatim to /. +# (e.g. eslint.json, phpstan.txt), creating the report directory as needed. +# This is a pure file write: it does not emit any JSONL event (see log_json +# above for that). Prints the path of the file written. +write_verify_report_file() { + local tool_name="${1:?tool_name required}" + local extension="${2:?extension required}" + local content="${3:-}" + local dir file + + dir="$(verify_report_dir)" + file="$dir/$tool_name.$extension" + printf '%s' "$content" >"$file" + printf '%s\n' "$file" +} diff --git a/lib/ai-verify/run.sh b/lib/ai-verify/run.sh new file mode 100644 index 0000000..6038a33 --- /dev/null +++ b/lib/ai-verify/run.sh @@ -0,0 +1,419 @@ +# shellcheck shell=bash +# Procedural verification flow for the AI verification gate. +# +# This module is sourced by scripts/ai/ai-verify.sh (the thin root loader); +# it is NOT an entrypoint and must not be executed directly. It is sourced LAST, +# after common.sh and every helper module, and the root loader calls +# ai_verify_run at the very end. +# +# ai_verify_run wraps the procedural body that used to run at the top level of +# the monolithic ai-verify.sh (the AI_VERIFY_TEST_MODE block plus the full +# repository/shellcheck/composer/pnpm/secrets/security flow). It deliberately +# uses the GLOBAL $failures (never a local) and preserves every top-level +# `exit` so the process-exit semantics are byte-for-byte identical to before. +# Only the file layout changed; no check logic, command, message, threshold, or +# ordering was altered. +# +# $failures is a global assigned by the root loader (scripts/ai/ai-verify.sh) +# before this module is sourced; this function intentionally mutates that global. +# shellcheck disable=SC2154 + +# Emit scoped changed files matching one or more git pathspec globs. The output +# includes modified/staged/untracked files and is de-duplicated. +scoped_changed_files_by_pathspec() { + local scope="${1:?scope required}" + shift + + case "$scope" in + branch) + local pattern + for pattern in "$@"; do + branch_scoped_files "$pattern" + done + ;; + changed) + git diff --name-only --diff-filter=ACMRT -- "$@" + git diff --cached --name-only --diff-filter=ACMRT -- "$@" + git ls-files --others --exclude-standard -- "$@" + ;; + *) + return 0 + ;; + esac | sort -u +} + +# True when any scoped changed file is exactly one of the provided paths. +scope_has_exact_changed_path() { + local scope="${1:?scope required}" + shift + + local changed wanted + while IFS= read -r changed; do + [[ -n "$changed" ]] || continue + for wanted in "$@"; do + if [[ "$changed" == "$wanted" ]]; then + return 0 + fi + done + done < <(scoped_changed_files_by_pathspec "$scope" "$@") + + return 1 +} + +# Advisory-only composer-unused check (docs/tickets/arch-todo-safe-language-verify-scripts-20260706-003959 +# §8-P6): reports packages that appear unused, but NEVER increments the global +# $failures tally, mirroring the advisory-tiering pattern already used by +# check_jscpd (35-jscpd.sh) for its default WARN-only tier. Full-gate only: +# called exclusively from the PHP VERIFY_FULL branch below. write_verify_report_file +# is defined in 54-reporting.sh, sourced BEFORE this module by scripts/ai/ai-verify.sh +# (see that file's load-ordered `source` list), so it is resolvable here both at +# source time and at this function's call time. +check_composer_unused() { + [[ -x vendor/bin/composer-unused ]] || return 0 + + echo "==> vendor/bin/composer-unused (advisory)" + + local output rc=0 + output="$(vendor/bin/composer-unused 2>&1)" || rc=$? + write_verify_report_file composer-unused txt "$output" >/dev/null 2>&1 || true + + if ((rc != 0)); then + log_warn "composer-unused reported findings (exit $rc); advisory only, does not fail verification. See ${AI_LOG_DIR:-.ai-logs}/verify/composer-unused.txt" + else + log_ok "composer-unused: no unused packages reported" + fi +} + +ai_verify_run() { + if [[ "${AI_VERIFY_TEST_MODE:-0}" == "1" ]]; then + echo "==> repository" + git status --short || true + echo "==> shellcheck" + echo "==> composer" + if [[ "${VERIFY_FULL:-0}" != "1" ]]; then + log_warn "Skipping full PHP test suite. Use VERIFY_FULL=1 to run phpunit/pest." + fi + check_line_counts + check_jscpd + check_plan_status + echo "==> done" + # Test mode stubs the heavy steps but still surfaces a real line-count + # failure so the URGENT-refactor gate is exercisable without a full run. + ((failures > 0)) && exit 1 + exit 0 + fi + + echo "==> repository" + git status --short || true + + check_line_counts + check_jscpd + check_plan_status + + if command -v shellcheck >/dev/null 2>&1; then + while IFS= read -r script; do + [[ -n "$script" ]] || continue + # SC1071: the linter only supports sh/bash/dash/ksh/busybox-sh. Skip + # scripts whose shebang is another shell (e.g. zsh); shfmt still covers + # their formatting below. + if IFS= read -r _first_line <"$script" 2>/dev/null && + [[ "$_first_line" == "#!"*zsh* || "$_first_line" == "#!"*fish* ]]; then + log_warn "Skipping shellcheck for $script: unsupported shell shebang (shellcheck only lints sh/bash/dash/ksh)." + continue + fi + # shellcheck disable=SC2086 + run_step "shellcheck $script" shellcheck $SHELLCHECK_ARGS "$script" + done < <(tracked_existing_shell_files) + fi + + if command -v shfmt >/dev/null 2>&1; then + while IFS= read -r script; do + [[ -n "$script" ]] || continue + run_step "shfmt -d $script" shfmt -d "$script" + done < <(tracked_existing_shell_files) + fi + + if command -v actionlint >/dev/null 2>&1 && [[ -d .github/workflows ]]; then + if is_changed_or_branch_scope; then + workflow_files=() + while IFS= read -r wf; do + [[ -n "$wf" ]] || continue + [[ -f "$wf" ]] || continue + should_skip_shipped_ai_kit_workflow_file "$wf" && continue + workflow_files+=("$wf") + done < <(scoped_changed_files_by_pathspec "$AI_VERIFY_SCOPE" '.github/workflows/*.yml' '.github/workflows/*.yaml') + + if ((${#workflow_files[@]} > 0)); then + run_step "actionlint (${#workflow_files[@]} changed workflow file(s))" actionlint "${workflow_files[@]}" + else + log_warn "Skipping actionlint in $AI_VERIFY_SCOPE scope: no changed workflow files in scope." + fi + else + run_step 'actionlint' actionlint + fi + fi + + if [[ "$VERIFY_LINKS" == "1" ]] && command -v lychee >/dev/null 2>&1; then + # Always offline: validate local file links only, never dial the network + # (so production URLs in docs are not contacted). Do not delegate to a + # target project's link-check wrapper because it may perform network checks. + run_step 'lychee --offline README.md docs/**/*.md' lychee --offline README.md docs/**/*.md + else + log_warn "Skipping link check. Use VERIFY_LINKS=1 to run lychee in offline mode." + fi + + if [[ -f composer.json ]]; then + if command -v composer >/dev/null 2>&1; then + if is_changed_or_branch_scope; then + if scope_has_exact_changed_path "$AI_VERIFY_SCOPE" composer.json composer.lock; then + run_step 'composer validate --strict' composer validate --strict + run_step 'composer audit' composer audit + else + log_warn "Skipping composer validate/audit in $AI_VERIFY_SCOPE scope: composer.json/composer.lock unchanged." + fi + else + run_step 'composer validate --strict' composer validate --strict + run_step 'composer audit' composer audit + fi + fi + + # Determine whether the PHP linters/analysers should be narrowed to + # changed files, branch files, or run project-wide. + # + # Default "ai" scope is intentionally local/dirty-only (same as changed) + # so plain `bash scripts/ai/ai-verify.sh .` stays fast and bounded in + # shipped target repos. Use AI_VERIFY_SCOPE=branch to include committed + # files unique to the current branch, or AI_VERIFY_SCOPE=all for explicit + # project-wide verification. + php_scoped=0 + php_all_excluding_shipped=0 + php_files=() + php_scope_source="$AI_VERIFY_SCOPE" + case "$AI_VERIFY_SCOPE" in + all) + # Explicit project-wide request. In the kit's own source repo we lint + # every file (php_scoped=0). In an installed target repo we still cover + # the whole project but pass an explicit file list that excludes the + # kit's shipped tools/ai/** files, so they are never linted. + if ! is_ai_kit_source_repo; then + php_scoped=1 + php_all_excluding_shipped=1 + fi + ;; + changed) + php_scoped=1 + ;; + ai) + php_scoped=1 + php_scope_source="changed" + ;; + branch) + php_scoped=1 + php_scope_source="branch" + ;; + *) + die "unknown AI_VERIFY_SCOPE: $AI_VERIFY_SCOPE" + ;; + esac + + # Human-readable description of what the scoped file list represents, used in + # the pint/phpstan/psalm step labels. + php_files_label="changed" + if ((php_scoped)); then + if ((php_all_excluding_shipped)); then + php_files_label="project, excluding shipped" + while IFS= read -r f; do + [[ -n "$f" ]] && php_files+=("$f") + done < <(all_php_files_excluding_shipped) + else + while IFS= read -r f; do + [[ -n "$f" ]] && php_files+=("$f") + done < <(AI_VERIFY_SCOPE="$php_scope_source" scoped_php_files) + fi + fi + + if [[ -x vendor/bin/pint ]]; then + if ((php_scoped)); then + if ((${#php_files[@]} > 0)); then + run_step "vendor/bin/pint --test (${#php_files[@]} ${php_files_label} file(s))" vendor/bin/pint --test "${php_files[@]}" + else + log_warn "No changed PHP files in scope ($AI_VERIFY_SCOPE); skipping pint." + fi + else + run_step 'vendor/bin/pint --test' vendor/bin/pint --test + fi + fi + + if [[ -x vendor/bin/phpstan ]]; then + if ((php_scoped)); then + if ((${#php_files[@]} > 0)); then + run_step "vendor/bin/phpstan analyse (${#php_files[@]} ${php_files_label} file(s))" vendor/bin/phpstan analyse --memory-limit=1G "${php_files[@]}" + else + log_warn "No changed PHP files in scope ($AI_VERIFY_SCOPE); skipping phpstan." + fi + else + run_step 'vendor/bin/phpstan analyse --memory-limit=1G' vendor/bin/phpstan analyse --memory-limit=1G + fi + fi + + if [[ -x vendor/bin/psalm ]]; then + if ((php_scoped)); then + if ((${#php_files[@]} > 0)); then + run_step "vendor/bin/psalm (${#php_files[@]} ${php_files_label} file(s))" vendor/bin/psalm --no-cache "${php_files[@]}" + else + log_warn "No changed PHP files in scope ($AI_VERIFY_SCOPE); skipping psalm." + fi + else + run_step 'vendor/bin/psalm --no-cache' vendor/bin/psalm --no-cache + fi + fi + + if [[ "$VERIFY_FULL" == "1" ]]; then + if [[ -x vendor/bin/phpunit ]]; then + run_step 'vendor/bin/phpunit' vendor/bin/phpunit + fi + + if [[ -x vendor/bin/pest ]]; then + run_step 'vendor/bin/pest' vendor/bin/pest + fi + + # Full-gate-only PHP architecture/dependency checks (§8-P6). + if [[ -x vendor/bin/deptrac ]]; then + run_step 'vendor/bin/deptrac analyse' vendor/bin/deptrac analyse + fi + + if [[ -x vendor/bin/composer-require-checker ]]; then + run_step 'vendor/bin/composer-require-checker check composer.json' vendor/bin/composer-require-checker check composer.json + fi + + check_composer_unused + else + log_warn "Skipping full PHP test suite. Use VERIFY_FULL=1 to run phpunit/pest." + fi + fi + + if [[ -f package.json ]]; then + if command -v pnpm >/dev/null 2>&1; then + if has_package_script lint; then + run_step_js 'pnpm run lint' pnpm run lint + elif has_package_dependency eslint; then + run_step_js 'pnpm exec eslint .' pnpm exec eslint . + fi + + if has_package_script typecheck; then + run_step_js 'pnpm run typecheck' pnpm run typecheck + elif [[ -f tsconfig.json ]] && has_package_dependency typescript; then + run_step_js 'pnpm exec tsc --noEmit' pnpm exec tsc --noEmit + fi + + if has_package_dependency vue-tsc; then + run_step_js 'pnpm exec vue-tsc --noEmit' pnpm exec vue-tsc --noEmit + fi + + if has_package_dependency nuxt || has_package_dependency nuxi; then + run_step_js 'pnpm exec nuxi typecheck' pnpm exec nuxi typecheck + fi + + if has_package_dependency @graphql-codegen/cli && [[ -f codegen.yml || -f codegen.yaml || -f codegen.ts ]]; then + run_step_js 'pnpm exec graphql-codegen' pnpm exec graphql-codegen + fi + + if has_package_dependency @graphql-eslint/eslint-plugin; then + run_step_js 'pnpm exec graphql-eslint .' pnpm exec graphql-eslint . + fi + + # Broadened Biome detection (name-partial `has_package_dependency + # biome` alone misses the real npm package `@biomejs/biome` and a + # bare `biome.json`/`biome.jsonc` config with no lockfile entry). + # This mirrors the dispatch that 50-tool-policy.sh's `can_run_tool + # biome` will centralize once that module is wired into the root + # loader (a later slice); kept inline here so today's pipeline + # benefits without requiring that not-yet-sourced module. + if has_package_dependency '@biomejs/biome' || has_package_dependency biome || [[ -f biome.json || -f biome.jsonc ]]; then + run_step_js 'pnpm exec biome check .' pnpm exec biome check . + fi + + if has_package_dependency knip; then + run_step_js 'pnpm exec knip' pnpm exec knip + fi + + if has_package_script test; then + if [[ "$VERIFY_FULL" == "1" ]]; then + run_step_js 'pnpm test' pnpm test + else + log_warn "Skipping full JS test suite. Use VERIFY_FULL=1 to run pnpm test." + fi + fi + + # Full-gate-only JS checks (§8-P6): dedicated Playwright/Vitest + # invocations, distinct from the generic `pnpm test` alias above, so a + # project without a `test` script alias still gets these covered. + if [[ "$VERIFY_FULL" == "1" ]]; then + if has_package_dependency '@playwright/test'; then + run_step_js 'pnpm exec playwright test' pnpm exec playwright test + fi + + if has_package_dependency vitest; then + run_step_js 'pnpm exec vitest run' pnpm exec vitest run + fi + fi + elif command -v npm >/dev/null 2>&1; then + if has_package_script lint; then + run_step 'npm run lint' npm run lint + fi + + if has_package_script typecheck; then + run_step 'npm run typecheck' npm run typecheck + fi + + if has_package_script test; then + if [[ "$VERIFY_FULL" == "1" ]]; then + run_step 'npm test' npm test + else + log_warn "Skipping full JS test suite. Use VERIFY_FULL=1 to run npm test." + fi + fi + fi + fi + + if [[ "$VERIFY_SECRETS" == "1" ]]; then + if command -v gitleaks >/dev/null 2>&1; then + run_step 'gitleaks detect --source . --redact --no-banner' gitleaks detect --source . --redact --no-banner + fi + else + log_warn "Skipping secret scan. Use VERIFY_SECRETS=1 to enable gitleaks." + fi + + # Broad, repo-wide security scanners (trivy/semgrep/osv-scanner) stay off by + # default in changed/branch scope; they run only when explicitly requested + # via AI_VERIFY_SCOPE=all (existing behavior) or the VERIFY_SECURITY=1 + # opt-in (new), so a per-language/changed-only run never silently pays the + # cost of a full-repo scan. + if is_changed_or_branch_scope && [[ "${VERIFY_SECURITY:-0}" != "1" ]]; then + log_warn "Skipping broad security scanners in $AI_VERIFY_SCOPE scope. Use AI_VERIFY_SCOPE=all or VERIFY_SECURITY=1 to run trivy/semgrep/osv-scanner." + elif command -v trivy >/dev/null 2>&1; then + run_step 'trivy fs --scanners vuln,misconfig,secret .' trivy fs --scanners vuln,misconfig,secret . + if command -v semgrep >/dev/null 2>&1; then + run_step 'semgrep scan --config auto .' semgrep scan --config auto . + fi + if command -v osv-scanner >/dev/null 2>&1; then + run_step 'osv-scanner scan source -r .' osv-scanner scan source -r . + fi + else + if command -v semgrep >/dev/null 2>&1; then + run_step 'semgrep scan --config auto .' semgrep scan --config auto . + fi + if command -v osv-scanner >/dev/null 2>&1; then + run_step 'osv-scanner scan source -r .' osv-scanner scan source -r . + fi + fi + + if ((failures > 0)); then + echo "==> failed: $failures verification step(s)" >&2 + log_json "verify.failed" "$(jq -cn --argjson failures "$failures" '{failures:$failures}')" || true + exit 1 + fi + + echo '==> done' + log_json "verify.passed" "$(jq -cn '{status:"passed"}')" || true +} diff --git a/lib/ai-verify/scope.sh b/lib/ai-verify/scope.sh new file mode 100644 index 0000000..5164661 --- /dev/null +++ b/lib/ai-verify/scope.sh @@ -0,0 +1,222 @@ +# shellcheck shell=bash +# File-scope discovery helpers for the AI verification gate. +# +# This module is sourced by scripts/ai/ai-verify.sh (the thin root loader); +# it is NOT an entrypoint and must not be executed directly. It is sourced +# AFTER common.sh and AFTER 20-shipped-filters.sh, so it may use common.sh +# helpers (e.g. log_*) and the shipped-file predicates. +# +# Behavior is byte-for-byte identical to the previous monolithic ai-verify.sh; +# only the file layout changed. The single exception is the git-branch-origin.sh +# path lookup in resolve_branch_base: the original used +# "$(dirname "${BASH_SOURCE[0]}")" which, from the root file, resolves to +# scripts/ai. Because this function now lives under internal/ai-verify/, the root +# loader exports the same directory as $_ai_verify_dir and we reference it here so +# the resolved path string is identical to before. + +# Resolve the merge-base between HEAD and the branch this branch was created from. +# Prints the merge-base commit, or nothing if it cannot be determined. +# +# Detection order: +# 1. explicit VERIFY_BASE_REF override +# 2. scripts/ai/git-branch-origin.sh (closest-merge-base + release-pattern aware) +# 3. fallback trunk list (origin/main -> origin/master -> main -> master) +resolve_branch_base() { + local candidate base + local origin_script + local detected="" + + # 1. Explicit override always wins. + if [[ -n "$VERIFY_BASE_REF" ]]; then + git rev-parse --verify --quiet "$VERIFY_BASE_REF^{commit}" >/dev/null 2>&1 || return 1 + base="$(git merge-base HEAD "$VERIFY_BASE_REF" 2>/dev/null || true)" + [[ -n "$base" ]] && printf '%s\n' "$base" && return 0 + return 1 + fi + + # 2. Prefer the smarter branch-origin detector when available. + # _ai_verify_dir is a global assigned by the root loader before this module + # is sourced (see scripts/ai/ai-verify.sh). + # shellcheck disable=SC2154 + origin_script="$_ai_verify_dir/git-branch-origin.sh" + if [[ -f "$origin_script" ]]; then + detected="$(bash "$origin_script" --field base 2>/dev/null || true)" + if [[ -n "$detected" ]]; then + printf '%s\n' "$detected" + return 0 + fi + fi + + # 3. Fallback trunk list. + for candidate in origin/main origin/master main master; do + git rev-parse --verify --quiet "$candidate^{commit}" >/dev/null 2>&1 || continue + if base="$(git merge-base HEAD "$candidate" 2>/dev/null)" && [[ -n "$base" ]]; then + printf '%s\n' "$base" + return 0 + fi + done + + return 1 +} + +# Files changed by the current branch since it diverged from its base, plus any +# uncommitted, staged, or untracked work. Respects $1 as a pathspec glob. +# Stops at the merge-base: shared history before the divergence is never touched. +branch_scoped_files() { + local glob="${1:?glob required}" + local base="" + + base="$(resolve_branch_base || true)" + + { + if [[ -n "$base" ]]; then + if [[ -n "$VERIFY_AUTHOR" ]]; then + # Only files from commits authored by VERIFY_AUTHOR on this branch. + local sha + while IFS= read -r sha; do + [[ -n "$sha" ]] || continue + git show --no-patch --format= --name-only --diff-filter=ACMRT "$sha" -- "$glob" + done < <(git rev-list --author="$VERIFY_AUTHOR" "$base..HEAD" 2>/dev/null) + else + git diff --name-only --diff-filter=ACMRT "$base"...HEAD -- "$glob" + fi + fi + # Always include local in-progress work regardless of authorship. + git diff --name-only --diff-filter=ACMRT -- "$glob" + git diff --cached --name-only --diff-filter=ACMRT -- "$glob" + git ls-files --others --exclude-standard -- "$glob" + } | sort -u +} + +# Emit existing changed PHP files according to AI_VERIFY_SCOPE. +# - branch: merge-base diff of the current branch + local work +# - changed: local working-tree/staged/untracked work only +# Returns no output for the project-wide scopes (ai/all), signalling callers to +# run the PHP tools project-wide as before. +scoped_php_files() { + local source_fn + case "$AI_VERIFY_SCOPE" in + branch) source_fn=branch ;; + changed) source_fn=changed ;; + *) return 0 ;; + esac + + { + if [[ "$source_fn" == branch ]]; then + branch_scoped_files '*.php' + else + git diff --name-only --diff-filter=ACMRT -- '*.php' + git diff --cached --name-only --diff-filter=ACMRT -- '*.php' + git ls-files --others --exclude-standard -- '*.php' + fi + } | + sort -u | + while IFS= read -r f; do + [[ -n "$f" ]] || continue + [[ -f "$f" ]] || continue + # In an installed target repo the kit's own tools/ai/**/*.php files + # are shipped support code, not the user's project code to lint. + should_skip_shipped_ai_kit_php_file "$f" && continue + printf '%s\n' "$f" + done +} + +# Emit every tracked/untracked PHP file except the kit's shipped tools/ai/** +# files. Used for AI_VERIFY_SCOPE=all in an installed target repo so a +# project-wide pint/phpstan/psalm run still never lints shipped support code. +all_php_files_excluding_shipped() { + git ls-files -co --exclude-standard -- '*.php' | + sort -u | + while IFS= read -r f; do + [[ -n "$f" ]] || continue + [[ -f "$f" ]] || continue + should_skip_shipped_ai_kit_php_file "$f" && continue + printf '%s\n' "$f" + done +} + +is_changed_or_branch_scope() { + [[ "$AI_VERIFY_SCOPE" == "changed" || "$AI_VERIFY_SCOPE" == "branch" ]] +} + +# Enumerate the files the line-count guardrail should inspect, honoring +# AI_VERIFY_SCOPE. The default scopes (ai/changed/branch) only look at files the +# current work touched (added/modified/renamed + staged + untracked); repository- +# wide inspection happens ONLY when the caller explicitly asks with +# AI_VERIFY_SCOPE=all. Binary blobs and the .git dir are never included. +linecount_scoped_files() { + case "$AI_VERIFY_SCOPE" in + all) + git ls-files -co --exclude-standard + ;; + branch) + branch_scoped_files '*' + ;; + *) + # ai (default) and changed both mean "only what this slice touched". + { + git diff --name-only --diff-filter=ACMRT + git diff --cached --name-only --diff-filter=ACMRT + git ls-files --others --exclude-standard + } | sort -u + ;; + esac +} + +tracked_existing_shell_files() { + case "$AI_VERIFY_SCOPE" in + ai) + # In an installed target repo the shipped scripts/ai/*.sh wrappers are + # not the user's code to verify; only self-verify them inside the kit's + # own authoring repository. + is_ai_kit_source_repo || return 0 + git ls-files -co --exclude-standard 'scripts/ai/*.sh' | + while IFS= read -r script; do + [[ -f "$script" ]] || continue + [[ "$script" == scripts/ai/check-batch*.sh ]] && continue + printf '%s\n' "$script" + done + ;; + changed) + { + git diff --name-only --diff-filter=ACMRT -- '*.sh' + git diff --cached --name-only --diff-filter=ACMRT -- '*.sh' + git ls-files --others --exclude-standard -- '*.sh' + } | + sort -u | + while IFS= read -r script; do + [[ -f "$script" ]] || continue + # In installed target repositories, AI-kit shell files are + # shipped support files. Changed-scope verification is for the + # user's slice, not re-linting shipped wrappers after install. + # Inside the kit's own source repo they remain in scope. + should_skip_shipped_ai_kit_shell_file "$script" && continue + [[ "$script" == scripts/ai/check-batch*.sh ]] && continue + printf '%s\n' "$script" + done + ;; + branch) + branch_scoped_files '*.sh' | + while IFS= read -r script; do + [[ -f "$script" ]] || continue + # Branch scope can include freshly installed kit shell files in + # a target repository; do not make shipped wrappers part of the + # target project's verification burden. Inside the kit's own + # source repo they remain in scope. + should_skip_shipped_ai_kit_shell_file "$script" && continue + [[ "$script" == scripts/ai/check-batch*.sh ]] && continue + printf '%s\n' "$script" + done + ;; + all) + git ls-files -co --exclude-standard '*.sh' | + while IFS= read -r script; do + [[ -f "$script" ]] || continue + printf '%s\n' "$script" + done + ;; + *) + die "unknown AI_VERIFY_SCOPE: $AI_VERIFY_SCOPE" + ;; + esac +} diff --git a/lib/ai-verify/shipped-filters.sh b/lib/ai-verify/shipped-filters.sh new file mode 100644 index 0000000..57ac027 --- /dev/null +++ b/lib/ai-verify/shipped-filters.sh @@ -0,0 +1,101 @@ +# shellcheck shell=bash +# Shipped AI-kit file predicates for the AI verification gate. +# +# This module is sourced by scripts/ai/ai-verify.sh (the thin root loader); +# it is NOT an entrypoint and must not be executed directly. It is sourced +# AFTER common.sh so it may use common.sh helpers, and BEFORE 10-scope.sh so the +# scope helpers can call should_skip_shipped_ai_kit_* predicates. +# +# These predicates decide whether a shipped kit file (scripts/ai/*.sh, +# tools/ai/*.php, install-*.sh, etc.) should be excluded from verification in an +# installed target repository while remaining in scope inside the kit's own +# authoring repo. Behavior is byte-for-byte identical to the previous monolithic +# ai-verify.sh; only the file layout changed. + +is_shipped_ai_kit_shell_file() { + case "$1" in + install-ai-kit.sh | \ + .github/hooks/scripts/*.sh | \ + scripts/ai/*.sh | \ + scripts/hooks/*.sh | \ + tools/ai/install-*.sh | \ + tools/ai/install/*.sh) + return 0 + ;; + esac + + return 1 +} + +# Shipped AI-kit workflow files. In installed target repositories these files are +# support scaffolding and should not become part of changed-scope verification. +is_shipped_ai_kit_workflow_file() { + case "$1" in + .github/workflows/validate-ai-surface.yml | \ + .github/workflows/test-external-install.yml | \ + .github/workflows/export-ai-universal-rules-preview.yml | \ + .github/workflows/architecture-plan-scope-guard.yml | \ + .github/workflows/auto-pr-description.yml | \ + .github/workflows/frontend-build.yml | \ + .github/workflows/pr-validation.yml) + return 0 + ;; + esac + + return 1 +} + +# Shipped AI-kit PHP files. The kit ships its tooling under tools/ai/**, so in an +# installed target repository those files are vendored support code, not the +# user's project code to lint with pint/phpstan/psalm. A case-glob '*' matches +# '/', so this single pattern covers every nesting depth under tools/ai/. +is_shipped_ai_kit_php_file() { + case "$1" in + tools/ai/*.php) + return 0 + ;; + esac + + return 1 +} + +# A shipped AI-kit shell file should be skipped only in an installed target +# repository. Inside the kit's own authoring repo these scripts ARE the product +# under test, so they must remain part of changed/branch verification here. +should_skip_shipped_ai_kit_shell_file() { + is_shipped_ai_kit_shell_file "$1" && ! is_ai_kit_source_repo +} + +# A shipped AI-kit PHP file should be skipped only in an installed target +# repository; inside the kit's own authoring repo it remains in scope. +should_skip_shipped_ai_kit_php_file() { + is_shipped_ai_kit_php_file "$1" && ! is_ai_kit_source_repo +} + +# A shipped AI-kit workflow file should be skipped only in an installed target +# repository; inside the kit's own authoring repo it remains in scope. +should_skip_shipped_ai_kit_workflow_file() { + is_shipped_ai_kit_workflow_file "$1" && ! is_ai_kit_source_repo +} + +# True only when running inside the AI-kit's own authoring repository, where the +# shipped scripts/ai/*.sh wrappers and install-*.sh scripts ARE the product +# under test and should be linted/formatted. Installed target repositories +# receive scripts/ai/* but never the kit package source authoring layout, so in +# a target the shipped wrappers must not become part of that project's +# verification burden. +# +# Detection requires the authoring-only artifacts together (not a single +# vendorable file like catalog.json), matching scripts/hooks/pre-commit.sh so +# "delivered vs source" is determined the same way across the kit. Override with +# AI_KIT_SELF_VERIFY=1 (force self-verify) or 0 (force target mode). +is_ai_kit_source_repo() { + case "${AI_KIT_SELF_VERIFY:-auto}" in + 1) return 0 ;; + 0) return 1 ;; + esac + [[ -d packages/ai-universal-rules/templates && + -f packages/ai-universal-rules/package-lock.ai.json && + -f tools/ai/ai.php && + -f tools/ai/generate-ai-catalog.php ]] +} diff --git a/lib/ai-verify/step-runner.sh b/lib/ai-verify/step-runner.sh new file mode 100644 index 0000000..344a2ee --- /dev/null +++ b/lib/ai-verify/step-runner.sh @@ -0,0 +1,100 @@ +# shellcheck shell=bash +# Step execution helpers for the AI verification gate. +# +# This module is sourced by scripts/ai/ai-verify.sh (the thin root loader); +# it is NOT an entrypoint and must not be executed directly. It is sourced +# AFTER common.sh so it may use run_guarded / run_with_timeout / log_* helpers. +# run_step mutates the global $failures tally and exports $last_step_rc. +# +# Behavior is byte-for-byte identical to the previous monolithic ai-verify.sh; +# only the file layout changed. The $last_step_rc=0 initialization keeps the same +# top-level ordering it had in the monolith (declared between run_step and +# run_step_js). + +run_step() { + local label="$1" + shift + + echo "==> $label" + + # Run under the hang/freeze watchdog: a hard wall-clock ceiling plus + # idle-output + idle-CPU detection that kills a stuck process group. Set + # VERIFY_GUARD=0 to fall back to the plain wall-clock timeout wrapper. + local rc=0 + if [[ "${VERIFY_GUARD:-1}" == "1" ]]; then + AI_GUARD_TIMEOUT="${AI_GUARD_TIMEOUT:-$VERIFY_TIMEOUT}" run_guarded "$label" "$@" || rc=$? + else + run_with_timeout "$VERIFY_TIMEOUT" "$@" || rc=$? + fi + + # Expose the last step's exit code without changing this function's own + # return semantics: run_step has always effectively returned success so that + # bare callers under `set -e` keep running every step and tally failures. + last_step_rc="$rc" + + if ((rc != 0)); then + echo "FAIL: $label failed (exit $rc)" >&2 + failures=$((failures + 1)) + fi +} + +last_step_rc=0 + +# Wrapper for pnpm/JS verification steps. Behaves exactly like run_step (same +# streaming, watchdog, and failure counting) but, on failure, runs a focused +# private-registry auth diagnostic so a missing token does not masquerade as a +# typecheck/lint failure. Does not alter exit-code or failure-count behavior. +run_step_js() { + local label="$1" + run_step "$@" + if ((last_step_rc != 0)); then + diagnose_pnpm_auth "$label" + fi +} + +# Detect the common "implicit pnpm install hit a private registry without a +# token" failure mode. pnpm runs a deps-status check before `pnpm exec`, so an +# unset ${NPM_TOKEN} referenced by .npmrc surfaces as ERR_PNPM_FETCH_401 on a +# step that looks like a typecheck. This check is deterministic (it inspects +# .npmrc + env, not captured output) and only prints an advisory hint. +diagnose_pnpm_auth() { + local label="${1:-pnpm step}" + local npmrc found_ref="" referenced_var="" + + for npmrc in .npmrc "$HOME/.npmrc"; do + [[ -f "$npmrc" ]] || continue + # Find an auth line that interpolates an env var, e.g. + # //npm.pkg.github.com/:_authToken=${NPM_TOKEN} + referenced_var="$( + sed -n 's/.*_authToken=\${\([A-Za-z_][A-Za-z0-9_]*\)}.*/\1/p' "$npmrc" 2>/dev/null | head -n1 + )" + if [[ -n "$referenced_var" ]]; then + found_ref="$npmrc" + break + fi + done + + [[ -n "$found_ref" ]] || return 0 + + # If the referenced token variable is unset/empty, the implicit install will + # fail with a 401 before the actual check runs. + if [[ -z "${!referenced_var:-}" ]]; then + log_warn "$label: '$found_ref' uses \${$referenced_var} for private-registry auth, but \$$referenced_var is unset." + log_warn "$label: a 401/ERR_PNPM_FETCH_401 here is almost certainly missing registry auth, not a real type/lint error." + log_warn "$label: set $referenced_var (token with read:packages) and re-run, e.g.: export $referenced_var=; pnpm install" + fi +} + +has_package_script() { + local script_name="${1:?script name required}" + [[ -f package.json ]] || return 1 + jq -e --arg name "$script_name" '.scripts[$name] // empty' package.json >/dev/null 2>&1 +} + +has_package_dependency() { + local package_name="${1:?package name required}" + [[ -f package.json ]] || return 1 + jq -e --arg name "$package_name" ' + (.dependencies[$name] // .devDependencies[$name] // .peerDependencies[$name] // empty) + ' package.json >/dev/null 2>&1 +} diff --git a/lib/ai-verify/tool-policy.sh b/lib/ai-verify/tool-policy.sh new file mode 100644 index 0000000..5d7b745 --- /dev/null +++ b/lib/ai-verify/tool-policy.sh @@ -0,0 +1,88 @@ +# shellcheck shell=bash +# Tool-availability policy layer for the AI verification gate. +# +# This module is sourced by scripts/ai/ai-verify.sh (the thin root loader); +# it is NOT an entrypoint and must not be executed directly. It is intended to +# be sourced AFTER 40-step-runner.sh (reuses its `has_package_dependency` / +# `has_package_script` jq-based package.json guards) and BEFORE 90-run.sh. +# +# As of this slice, the root loader (scripts/ai/ai-verify.sh) does NOT yet +# source this file — that wiring is a later, separately-approved slice. This +# module is written to be standalone-sourceable (source scripts/ai/common.sh, +# then this file) so it is directly unit-testable today without depending on +# the root loader or any other internal module. It defines no new PATH-based +# `has_node_dependency`/`has_node_script` guards: `has_package_dependency` and +# `has_package_script` already exist in 40-step-runner.sh and are reused as-is +# per the repo's `>=75%` reuse rule. +# +# Purpose: centralize the "is a tool safely runnable here" decision that is +# currently scattered as inline `command -v` / `[[ -x vendor/bin/... ]]` / +# `has_package_dependency` checks throughout 90-run.sh, so a future +# per-language dispatcher (a later slice) can ask one function instead of +# re-deriving the same guard per tool. Nothing here mutates state, installs +# anything, or runs a tool from an unapproved source (no PATH fallback for +# framework-specific tools, no `npx --yes`, no `composer global`). + +# True (exit 0) for the small, fixed set of tools that are safe to invoke from +# PATH regardless of project type: they are standalone binaries, not tied to a +# specific package.json/composer.json dependency tree, and every existing +# caller already guards them with a plain `command -v` check (90-run.sh:89-130, +# 324-349; 35-jscpd.sh; ai-verify.sh VERIFY_LINKS). This is a fixed allowlist, +# not a general "is this on PATH" predicate, so it must never be extended to +# cover a framework-local tool such as `eslint` or `phpstan`. +is_standalone_safe_tool() { + local tool="${1:?tool name required}" + case "$tool" in + shellcheck | shfmt | actionlint | gitleaks | trivy | semgrep | osv-scanner | lychee) + return 0 + ;; + *) + return 1 + ;; + esac +} + +# True when a composer-managed binary is present and executable under +# vendor/bin/. Mirrors the existing inline `[[ -x vendor/bin/pint ]]`-style +# guards in 90-run.sh; never falls back to a PATH-installed copy of the same +# tool, since a global install would not reflect this project's pinned +# composer.json/composer.lock version. +has_composer_bin() { + local bin="${1:?binary name required}" + [[ -x "vendor/bin/$bin" ]] +} + +# Single dispatcher answering "can $tool run safely right now, using only +# what this project already has installed?". Callers should prefer this over +# re-deriving the guard inline once a caller opts into it; existing inline +# guards in 90-run.sh are left as-is in this slice (no behavior change there +# beyond the three targeted fixes) and may be routed through this dispatcher +# in a later slice. +can_run_tool() { + local tool="${1:?tool name required}" + case "$tool" in + pint | phpstan | psalm | phpunit | pest | rector | phpmd | deptrac) + has_composer_bin "$tool" + ;; + eslint) + has_package_dependency eslint + ;; + biome) + has_package_dependency '@biomejs/biome' || + has_package_dependency biome || + [[ -f biome.json || -f biome.jsonc ]] + ;; + vue-tsc) + has_package_dependency vue-tsc + ;; + nuxt | nuxi) + has_package_dependency nuxt || has_package_dependency nuxi + ;; + knip) + has_package_dependency knip + ;; + *) + is_standalone_safe_tool "$tool" && command -v "$tool" >/dev/null 2>&1 + ;; + esac +} diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..00622aa --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Shared compatibility facade for repository AI tooling scripts. +# +# This file is a thin facade. All logic lives in ordered modules under +# scripts/ai/internal/lib/, sourced here in 00->90 order. Each module is idempotent +# (unique source guard) so re-sourcing common.sh is safe. Dependent scripts +# source ONLY this file; do not source lib modules directly. +# +# Module map: +# 00-env.sh env defaults, color vars +# 05-core.sh logging primitives, command/version probes, generic utils +# 10-json.sh JSON, redaction, envelope helpers +# 20-paths.sh path/repo validation, tool discovery +# 30-logging.sh structured event logging +# 31-log-redaction.sh logging redaction seam (wraps 10-json redaction) +# 40-session.sh agent session init +# 50-policy.sh command classification and approval policy +# 60-exec-guard.sh timeout and hang/freeze guards +# 70-secrets.sh secret scanning +# 80-tokens.sh token estimation and previews +# 90-snapshot.sh snapshot create/apply (rollback mechanism) + +set -euo pipefail + +# Universal --introspect guard. When a script that sources this file is invoked +# with `--introspect` as its FIRST argument, emit that script's machine-readable +# JSON contract (via the static introspector) and exit, WITHOUT running any of +# the script's own logic. The target script is parsed statically, never executed. +# +# This gives every common.sh-sourcing script a uniform `--introspect` surface. +# Scripts that need to handle `--introspect` earlier (e.g. before sourcing, like +# ai-search.sh) still can; this guard only runs when reached. It is a no-op when +# the first argument is anything other than `--introspect`. +if [[ "${1:-}" == "--introspect" ]]; then + _ai_introspect_here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # Target = the script that sourced common.sh; when common.sh is run directly + # (no sourcer), introspect common.sh itself. + _ai_introspect_target="${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}" + _ai_introspect_tool="$_ai_introspect_here/../libexec/sh-introspect" + if [[ -n "$_ai_introspect_target" && -x "$_ai_introspect_tool" ]]; then + exec env AI_OUTPUT=json bash "$_ai_introspect_tool" "$_ai_introspect_target" + fi +fi + +# Universal --help/-h guard. Sibling of the --introspect guard above: when a +# common.sh-sourcing script is invoked with `--help`/`-h` as its FIRST argument, +# emit that script's human-readable contract (the static introspector's compact +# `--format=help` view) and exit WITHOUT running the script's own logic. This +# gives every common.sh-sourcing script a uniform `--help` surface and prevents +# scripts that otherwise consume positional args from acting on `--help`. +# Scripts that define their own richer `--help` should handle it BEFORE sourcing +# common.sh (early guard); this fallback only runs when reached. Scripts already +# carrying a `--help` flag in their own parser are unaffected because they handle +# it before this point is reached only when sourced first — so the early-handling +# scripts (e.g. ai-search.sh) keep their bespoke help. +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + _ai_help_here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + _ai_help_target="${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}" + _ai_help_tool="$_ai_help_here/../libexec/sh-introspect" + if [[ -n "$_ai_help_target" && -x "$_ai_help_tool" ]]; then + exec bash "$_ai_help_tool" --format=help "$_ai_help_target" + fi +fi + +_AI_COMMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +_AI_COMMON_LIB_DIR="${_AI_COMMON_DIR}" + +# shellcheck source=lib/environment.sh +source "${_AI_COMMON_LIB_DIR}/environment.sh" +# shellcheck source=lib/core.sh +source "${_AI_COMMON_LIB_DIR}/core.sh" +# shellcheck source=lib/json.sh +source "${_AI_COMMON_LIB_DIR}/json.sh" +# shellcheck source=lib/paths.sh +source "${_AI_COMMON_LIB_DIR}/paths.sh" +# shellcheck source=lib/logging.sh +source "${_AI_COMMON_LIB_DIR}/logging.sh" +# shellcheck source=lib/log-redaction.sh +source "${_AI_COMMON_LIB_DIR}/log-redaction.sh" +# shellcheck source=lib/session.sh +source "${_AI_COMMON_LIB_DIR}/session.sh" +# shellcheck source=lib/policy.sh +source "${_AI_COMMON_LIB_DIR}/policy.sh" +# shellcheck source=lib/exec-guard.sh +source "${_AI_COMMON_LIB_DIR}/exec-guard.sh" +# shellcheck source=lib/secrets.sh +source "${_AI_COMMON_LIB_DIR}/secrets.sh" +# shellcheck source=lib/tokens.sh +source "${_AI_COMMON_LIB_DIR}/tokens.sh" +# shellcheck source=lib/snapshot.sh +source "${_AI_COMMON_LIB_DIR}/snapshot.sh" diff --git a/lib/core.sh b/lib/core.sh new file mode 100644 index 0000000..63625cb --- /dev/null +++ b/lib/core.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# 05-core.sh — primitive core helpers and generic utilities. +# +# Purpose: low-level logging primitives, command/version probes, and small +# generic utilities used everywhere. +# Allowed dependencies: 00-env.sh (color vars). die() calls log_json (30-logging) +# at run time, which is resolved lazily by bash — no source-time cross-calls. + +[[ "${AI_LIB_CORE_LOADED:-0}" == "1" ]] && return 0 +AI_LIB_CORE_LOADED=1 + +log_info() { printf '%b[INFO]%b %s\n' "$_C_CYAN" "$_C_RESET" "$*" >&2; } +log_ok() { printf '%b[OK]%b %s\n' "$_C_GREEN" "$_C_RESET" "$*" >&2; } +log_warn() { printf '%b[WARN]%b %s\n' "$_C_YELLOW" "$_C_RESET" "$*" >&2; } +log_error() { printf '%b[ERROR]%b %s\n' "$_C_RED" "$_C_RESET" "$*" >&2; } + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +require_bash_version() { + local min="${1:-4}" + ((BASH_VERSINFO[0] >= min)) || die "bash $min+ required" +} + +now_ms() { + local s + s="$(date +%s)" + printf '%s000\n' "$s" +} + +die() { + log_error "$*" + log_json "error" "$(jq -cn --arg msg "$*" '{msg:$msg}')" || true + exit 1 +} + +section() { + printf '\n%b==> %s%b\n' "$_C_BOLD" "$*" "$_C_RESET" >&2 +} + +require_bins() { + local missing=() + local bin + for bin in "$@"; do + command -v "$bin" >/dev/null 2>&1 || missing+=("$bin") + done + if ((${#missing[@]} > 0)); then + die "required tools not found: ${missing[*]}" + fi +} + +wait_for_capture_flag() { + local f="${1:?flag required}" + [[ -s "$f" ]] && return 0 + printf 'true' >"$f" +} + +ai_load_config_list() { + local -n _ai_list_ref=$1 + local _ai_list_file="$2" + shift 2 + + _ai_list_ref=() + if [[ -f "$_ai_list_file" ]]; then + local _ai_line + while IFS= read -r _ai_line || [[ -n "$_ai_line" ]]; do + _ai_line="${_ai_line%%#*}" + _ai_line="${_ai_line#"${_ai_line%%[![:space:]]*}"}" + _ai_line="${_ai_line%"${_ai_line##*[![:space:]]}"}" + [[ -n "$_ai_line" ]] || continue + _ai_list_ref+=("$_ai_line") + done <"$_ai_list_file" + fi + + if ((${#_ai_list_ref[@]} == 0)); then + _ai_list_ref=("$@") + fi +} diff --git a/lib/environment.sh b/lib/environment.sh new file mode 100644 index 0000000..cd1c119 --- /dev/null +++ b/lib/environment.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# 00-env.sh — environment defaults and color variables. +# +# Purpose: declare the canonical AI_* directory and log path defaults, export +# GIT_CONFIG_GLOBAL, and define the _C_* color variables. +# Allowed dependencies: none. This module must only assign variables — no +# mkdir, git, jq, logging, or snapshot logic, and no calls into other modules. +# +# Naming: AI_* is the universal, runtime-agnostic contract. The legacy COPILOT_* +# variables are still ACCEPTED as input fallbacks (so a user who exported +# COPILOT_LOG_DIR keeps working), but they are no longer re-exported and no +# shipped script reads them. Prefer AI_* everywhere. + +[[ "${AI_LIB_ENV_LOADED:-0}" == "1" ]] && return 0 +AI_LIB_ENV_LOADED=1 + +AI_LOG_DIR="${AI_LOG_DIR:-${COPILOT_LOG_DIR:-.ai-logs}}" +AI_CONTEXT_DIR="${AI_CONTEXT_DIR:-${COPILOT_CONTEXT_DIR:-.repomix-context}}" +AI_SESSION_DIR="${AI_SESSION_DIR:-${COPILOT_SESSION_DIR:-${AI_LOG_DIR}/sessions}}" +AI_SNAPSHOT_DIR="${AI_SNAPSHOT_DIR:-${COPILOT_SNAPSHOT_DIR:-${AI_LOG_DIR}/snapshots}}" +AI_EVENT_LOG="${AI_EVENT_LOG:-${COPILOT_EVENT_LOG:-${AI_LOG_DIR}/tool-usage.jsonl}}" +AI_SESSION_GENERATED_DIR="${AI_SESSION_GENERATED_DIR:-docs/ai/generated/sessions}" + +# Keep repo-local git reads working inside IDE sandboxes that cannot access the +# user's global include chain (for example ~/.gitconfig-work on macOS). +export GIT_CONFIG_GLOBAL="${GIT_CONFIG_GLOBAL:-/dev/null}" + +if [[ -z "${NO_COLOR:-}" ]] && [[ -t 2 ]]; then + _C_RESET=$'\033[0m' + _C_RED=$'\033[0;31m' + _C_YELLOW=$'\033[0;33m' + _C_GREEN=$'\033[0;32m' + _C_CYAN=$'\033[0;36m' + _C_BOLD=$'\033[1m' +else + _C_RESET='' + _C_RED='' + _C_YELLOW='' + _C_GREEN='' + _C_CYAN='' + _C_BOLD='' +fi diff --git a/lib/exec-guard.sh b/lib/exec-guard.sh new file mode 100644 index 0000000..d694a13 --- /dev/null +++ b/lib/exec-guard.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# 60-exec-guard.sh — timeout and hang/freeze execution guards (thin loader). +# +# Purpose: hard-timeout wrapper, idle/hung-process watchdog, process-group kill, +# and CPU sampling helpers. Private helpers use the _ai_guard_ prefix. +# Allowed dependencies: 05-core.sh (log_warn), 30-logging.sh (log_json), +# 20-paths.sh (timeout discovery is inline here). No policy, approval prompts, +# snapshots, or secret scanning. +# +# The implementation lives in load-ordered modules under exec-guard/; this file +# stays the sourced facade (common.sh sources it by path) and preserves the +# one-time load guard. Behavior is byte-for-byte identical to the previous +# monolithic version. + +[[ "${AI_LIB_EXEC_GUARD_LOADED:-0}" == "1" ]] && return 0 +AI_LIB_EXEC_GUARD_LOADED=1 + +_ai_exec_guard_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/exec-guard" +# shellcheck source=lib/exec-guard/run-timeout.sh +source "$_ai_exec_guard_dir/run-timeout.sh" +# shellcheck source=lib/exec-guard/cpu-sampling.sh +source "$_ai_exec_guard_dir/cpu-sampling.sh" +# shellcheck source=lib/exec-guard/kill-tree.sh +source "$_ai_exec_guard_dir/kill-tree.sh" +# shellcheck source=lib/exec-guard/run-guarded.sh +source "$_ai_exec_guard_dir/run-guarded.sh" +unset _ai_exec_guard_dir diff --git a/lib/exec-guard/cpu-sampling.sh b/lib/exec-guard/cpu-sampling.sh new file mode 100644 index 0000000..f9e2977 --- /dev/null +++ b/lib/exec-guard/cpu-sampling.sh @@ -0,0 +1,120 @@ +# shellcheck shell=bash +# exec-guard/20-cpu-sampling.sh — per-process / process-group CPU sampling. +# +# Sourced via scripts/ai/internal/lib/60-exec-guard.sh (loader). Not an +# entrypoint. Private helpers use the _ai_guard_ prefix. Behavior is +# byte-for-byte identical to the previous monolithic 60-exec-guard.sh. + +# utime+stime jiffies for a single pid, parsed from /proc//stat. The comm +# field (field 2) is wrapped in parentheses and may contain spaces, so we strip +# everything through the final ") " before splitting. After that strip, the +# state field is index 0, making utime index 11 and stime index 12. +_ai_guard_pid_jiffies() { + local pid="$1" + [[ -r "/proc/$pid/stat" ]] || return 1 + local raw rest + raw="$(cat "/proc/$pid/stat" 2>/dev/null)" || return 1 + rest="${raw##*) }" + local -a a + # read returns nonzero on the no-trailing-newline herestring; tolerate it + # so callers under `set -e` are not aborted. + read -r -a a <<<"$rest" || true + printf '%s\n' "$((${a[11]:-0} + ${a[12]:-0}))" +} + +# Sum used CPU jiffies (utime+stime) for an entire process group, from /proc. +# When the guarded command is started with setsid it leads its own group, so the +# group id equals the child pid and this captures every descendant (including +# transient forked grandchildren that a direct-children walk would miss). +# Prints the total or returns nonzero when /proc is unavailable. +_ai_guard_proc_jiffies() { + local pgid="${1:?pgid required}" + [[ -d /proc ]] || return 1 + local total=0 d pid raw rest matched=0 + local -a a + + # Seed with the group leader's own jiffies so a racy /proc scan (processes + # appearing/vanishing mid-walk) can never make this return empty while the + # leader is still alive. The leader (pid == pgid) is then skipped in the loop + # to avoid double counting. + if raw="$(cat "/proc/$pgid/stat" 2>/dev/null)"; then + rest="${raw##*) }" + read -r -a a <<<"$rest" || true + total=$((${a[11]:-0} + ${a[12]:-0})) + matched=1 + fi + + for d in /proc/[0-9]*; do + pid="${d#/proc/}" + [[ "$pid" == "$pgid" ]] && continue + raw="$(cat "/proc/$pid/stat" 2>/dev/null)" || continue + rest="${raw##*) }" + read -r -a a <<<"$rest" || true + # After stripping comm, indices: 0=state ... 2=pgrp ... 11=utime 12=stime + [[ "${a[2]:-}" == "$pgid" ]] || continue + matched=1 + total=$((total + ${a[11]:-0} + ${a[12]:-0})) + done + + [[ "$matched" == "1" ]] || return 1 + printf '%s\n' "$total" +} + +# Print a process's instantaneous CPU percent over a short sampling window. +# Prefers a /proc jiffies delta (accurate, instantaneous, includes children); +# falls back to `ps -o %cpu` (lifetime average) when /proc is absent (macOS/BSD). +# Returns nonzero if the platform cannot be sampled at all. +# Args: [sample_seconds] [group_mode] +# group_mode=1 sums the whole process group (pid == pgid, i.e. started under +# setsid) so transient forked grandchildren are counted; otherwise just the pid. +# Public wrapper: runs the implementation with errexit disabled and restores the +# caller's errexit afterwards, so the flag never leaks into callers under set -e. +_ai_guard_cpu_percent() { + local _errexit_was_set=0 _rc=0 _out="" + case "$-" in *e*) _errexit_was_set=1 ;; esac + set +e + _out="$(_ai_guard_cpu_percent_impl "$@")" + _rc=$? + ((_errexit_was_set)) && set -e + [[ -n "$_out" ]] && printf '%s\n' "$_out" + return "$_rc" +} + +_ai_guard_cpu_percent_impl() { + local pid="${1:?pid required}" + local sample="${2:-1}" + local group_mode="${3:-0}" + + if [[ -r "/proc/$pid/stat" ]]; then + local j0 j1 hz + if ((group_mode)); then + j0="$(_ai_guard_proc_jiffies "$pid")" || return 1 + else + j0="$(_ai_guard_pid_jiffies "$pid")" || return 1 + fi + sleep "$sample" + kill -0 "$pid" 2>/dev/null || { + printf '0\n' + return 0 + } + if ((group_mode)); then + j1="$(_ai_guard_proc_jiffies "$pid")" || return 1 + else + j1="$(_ai_guard_pid_jiffies "$pid")" || return 1 + fi + hz="$(getconf CLK_TCK 2>/dev/null || echo 100)" + # percent = (delta jiffies / hz) / sample_seconds * 100 + awk -v d="$((j1 - j0))" -v hz="$hz" -v s="$sample" \ + 'BEGIN{ if (s<=0) s=1; printf "%.1f\n", (d/hz)/s*100 }' + return 0 + fi + + if command -v ps >/dev/null 2>&1; then + local v + v="$(ps -o %cpu= -p "$pid" 2>/dev/null | tr -d ' ')" + [[ -n "$v" ]] || return 1 + printf '%s\n' "$v" + return 0 + fi + return 1 +} diff --git a/lib/exec-guard/kill-tree.sh b/lib/exec-guard/kill-tree.sh new file mode 100644 index 0000000..4c22be8 --- /dev/null +++ b/lib/exec-guard/kill-tree.sh @@ -0,0 +1,26 @@ +# shellcheck shell=bash +# exec-guard/30-kill-tree.sh — process-group termination. +# +# Sourced via scripts/ai/internal/lib/60-exec-guard.sh (loader). Not an +# entrypoint. Behavior is byte-for-byte identical to the previous monolithic +# 60-exec-guard.sh. + +# Terminate a process group: SIGTERM, grace, then SIGKILL. When the child led +# its own session (setsid), signal the whole group via -PID. +_ai_guard_kill_tree() { + local pid="${1:?pid required}" + local use_setsid="${2:-0}" + local grace="${3:-5}" + local target="$pid" + ((use_setsid)) && target="-$pid" + + kill -TERM "$target" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true + local waited=0 + while kill -0 "$pid" 2>/dev/null && ((waited < grace)); do + sleep 1 + waited=$((waited + 1)) + done + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$target" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true + fi +} diff --git a/lib/exec-guard/run-guarded.sh b/lib/exec-guard/run-guarded.sh new file mode 100644 index 0000000..7f260ad --- /dev/null +++ b/lib/exec-guard/run-guarded.sh @@ -0,0 +1,190 @@ +# shellcheck shell=bash +# exec-guard/40-run-guarded.sh — hang/freeze watchdog around a command. +# +# Sourced via scripts/ai/internal/lib/60-exec-guard.sh (loader), AFTER the +# timeout wrapper, CPU sampling, and kill-tree helpers it depends on. Not an +# entrypoint. Behavior is byte-for-byte identical to the previous monolithic +# 60-exec-guard.sh. + +# run_guarded — run a command under a hang/freeze watchdog. +# +# Terminates the command's process group when ANY of these trip: +# 1. wall-clock > AI_GUARD_TIMEOUT seconds (hard ceiling) +# 2. hung/idle = no new stdout/stderr output AND the child's CPU is +# ~idle, sustained for AI_GUARD_IDLE_SECS seconds +# +# The idle trigger requires BOTH no-output AND idle-CPU, so a silent-but-busy +# job (CPU-bound compile) or a CPU-light-but-streaming job (download) is not +# killed by mistake. CPU sampling degrades gracefully: if it is unavailable the +# guard keeps the wall-clock ceiling and logs which signals are active. +# +# Usage: run_guarded