diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..39f1a41 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,67 @@ +# EditorConfig — https://editorconfig.org +# Canonical eco-wide template (.shared-templates/editorconfig.tmpl). + +root = true + +# Default for everything. +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +# Go uses tabs by convention. +[*.go] +indent_style = tab +indent_size = 4 + +# Python — PEP 8. +[*.py] +indent_size = 4 + +# TypeScript / JavaScript — 2 spaces, ecosystem default. +[*.{ts,tsx,js,jsx,mjs,cjs}] +indent_size = 2 + +# Web assets. +[*.{html,css,scss}] +indent_size = 2 + +# YAML — 2 spaces (ecosystem standard, GitHub Actions, k8s, etc.). +[*.{yml,yaml}] +indent_size = 2 + +# JSON / JSONC. +[*.{json,jsonc}] +indent_size = 2 + +# TOML. +[*.toml] +indent_size = 2 + +# Markdown — 2 spaces, preserve trailing whitespace (used for line breaks). +[*.md] +trim_trailing_whitespace = false +indent_size = 2 + +# Shell scripts. +[*.{sh,bash,zsh,fish}] +indent_size = 4 + +# Makefiles must use tabs. +[{Makefile,*.mk}] +indent_style = tab + +# Dockerfiles. +[Dockerfile*] +indent_size = 4 + +# GitHub Actions workflows — 2 spaces. +[.github/**/*.{yml,yaml}] +indent_size = 2 + +# Config files. +[*.{cfg,ini,conf}] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3342e8f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,86 @@ +# Canonical eco-wide .gitattributes template (.shared-templates/gitattributes.tmpl). +# Auto-detect text files and normalise line endings to LF. + +* text=auto eol=lf + +# --- Source code ----------------------------------------------------------- +*.go text eol=lf diff=golang +*.py text eol=lf diff=python +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.mjs text eol=lf +*.cjs text eol=lf +*.rs text eol=lf diff=rust + +# --- Shell + config -------------------------------------------------------- +*.sh text eol=lf +*.bash text eol=lf +*.toml text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.json text eol=lf linguist-language=JSON +*.jsonc text eol=lf linguist-language=JSON +*.cff text eol=lf + +# --- Documentation --------------------------------------------------------- +*.md text eol=lf diff=markdown +*.txt text eol=lf + +# --- Build / packaging ---------------------------------------------------- +Makefile text eol=lf +*.mk text eol=lf +Dockerfile* text eol=lf +docker-compose*.yml text eol=lf +.github/**/*.yml text eol=lf +.github/**/*.yaml text eol=lf + +# --- Generated artefacts (mark as such for diffs and language stats) ------ +go.mod text eol=lf linguist-generated +go.sum text eol=lf linguist-generated +*.pb.go linguist-generated +*_generated.go linguist-generated +package-lock.json linguist-generated +pnpm-lock.yaml linguist-generated +yarn.lock linguist-generated + +# --- Vendored / external sources ------------------------------------------ +vendor/** linguist-vendored +node_modules/** linguist-vendored +testdata/** linguist-vendored +benchmarks/data/** linguist-vendored + +# --- Binary files (do not text-normalise) --------------------------------- +*.exe binary +*.dll binary +*.so binary +*.dylib binary +*.a binary +*.o binary +*.db binary +*.sqlite binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.svg text eol=lf +*.pdf binary +*.zip binary +*.tar.gz binary +*.tgz binary +*.whl binary + +# --- Source archive hygiene (excluded from `git archive`) ----------------- +.github export-ignore +.shared-templates export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.editorconfig export-ignore +.golangci.yml export-ignore +.goreleaser.yml export-ignore +.goreleaser.yaml export-ignore +testdata/ export-ignore +benchmarks/ export-ignore +e2e/ export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..daf4343 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,114 @@ +name: Bug report +description: Something is broken or behaving unexpectedly. +title: "bug: " +labels: ["bug", "triage"] + +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug report. Please fill in as much + of the form as you can — the more we know, the faster we can fix it. + + Before submitting: + - Search [existing issues](https://github.com/GrayCodeAI/sight/issues) to avoid duplicates. + - If this is a security issue, please **do not** file a public issue. See `SECURITY.md`. + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear, concise description of the bug. + placeholder: When I call sight.Review(...) on diff X, I expected Y but got Z. + validations: + required: true + + - type: dropdown + id: surface + attributes: + label: Surface + description: How are you using sight? + options: + - "Library API (`sight.Review`, `sight.Describe`, `sight.Improve`)" + - "MCP (`sight_review`, `sight_describe`, `sight_improve` tools)" + - "SARIF output" + - "Static rules / convention checks" + - "Eval framework" + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Minimal Go snippet, MCP call, or diff that reliably reproduces the problem. + render: go + placeholder: | + diff := `--- a/foo.go + +++ b/foo.go + @@ ... @@` + result, err := sight.Review(ctx, diff, sight.WithProvider(p), sight.Thorough) + // ^ wrong finding category / missed finding / panic / etc. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen instead? + validations: + required: true + + - type: textarea + id: false-positive + attributes: + label: If this is a false positive / false negative + description: | + Paste the relevant finding (or the missing one), and explain why you + think the verdict is wrong. Include the snippet of code that + triggered (or should have triggered) it. + + - type: input + id: sight-version + attributes: + label: sight version + description: Output of `sight version` (or the git SHA you built from). + placeholder: "0.2.0" + validations: + required: true + + - type: input + id: go-version + attributes: + label: Go version + description: Output of `go version`. + placeholder: "go version go1.26.1 darwin/arm64" + validations: + required: true + + - type: input + id: provider + attributes: + label: LLM provider used + description: Which provider/model? (Anthropic Claude Opus, OpenAI GPT-5, local Ollama, mock, etc.) + placeholder: "anthropic claude-opus-4-20250514" + + - type: textarea + id: logs + attributes: + label: Logs / output + description: | + Paste relevant output. **Redact any secrets, API keys, proprietary + code, and identifying repo paths first.** + render: shell + + - type: checkboxes + id: confirm + attributes: + label: Confirmation + options: + - label: I searched existing issues and did not find a duplicate. + required: true + - label: I redacted any secrets, API keys, or private code from logs. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1646eed --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/GrayCodeAI/sight/security/advisories/new + about: Please report security issues privately via a GitHub Security Advisory. See SECURITY.md. + - name: Question / discussion + url: https://github.com/GrayCodeAI/sight/discussions + about: Have a question or want to discuss an idea? Open a discussion instead of an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..3d22e6f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,71 @@ +name: Feature request +description: Suggest an improvement, a new review concern, or a new integration. +title: "feat: " +labels: ["enhancement", "triage"] + +body: + - type: markdown + attributes: + value: | + Thanks for proposing a feature. sight is the AI code-review library that + backs hawk's review pipeline. Every feature is evaluated against whether + it serves **a single developer** running an AI agent locally — i.e. it + either improves review precision/recall, lowers latency, or simplifies + integration. + + Before submitting: + - Search [existing issues](https://github.com/GrayCodeAI/sight/issues) to avoid duplicates. + + - type: dropdown + id: kind + attributes: + label: Kind of feature + description: What flavour of change is this? + options: + - "Review concerns / multi-concern pipeline" + - "Static rules / convention checks" + - "SARIF output" + - "MCP server / tools" + - "Configuration (.sight/, options)" + - "Eval framework" + - "Output format / inline comments / describe / improve" + - "Tooling / CI / docs" + validations: + required: true + + - type: textarea + id: problem + attributes: + label: What problem are you trying to solve? + description: Describe the user problem first. Solutions can come later. + placeholder: When I review diff X, sight misses Y / surfaces too many Z. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: How would you like sight to behave? Library snippet, MCP tool shape, config sample. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: | + What did you try? What do other code-review tools (CodeRabbit, Sourcery, + DeepSource, Qodana, Semgrep, Sourcegraph code-review, danger-js) do? + Why isn't that enough? + + - type: checkboxes + id: principles + attributes: + label: Solo-developer fit + description: sight avoids enterprise scope. Confirm this feature respects that. + options: + - label: Works with zero configuration (sensible defaults). + - label: Does not require a network call beyond the user's chosen LLM provider. + - label: Stores any state locally (under `.sight/` in the repo). + - label: Has an escape hatch (override via option, env, or config). diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..89aa8b5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,78 @@ + + +## Summary + + + +## Changes + + + +- + +## Review-quality impact + + + +## SARIF compatibility + + + +## Testing + + + +```text +$ make test +... +$ make lint +... +``` + +## Checklist + +- [ ] Commits follow [Conventional Commits](https://www.conventionalcommits.org/) + (`feat:`, `fix:`, `perf:`, `refactor:`, `docs:`, `test:`, etc.) +- [ ] `make build` passes +- [ ] `make lint` passes +- [ ] `make test-race` passes locally +- [ ] New or changed code has tests (table-driven where appropriate) +- [ ] Public APIs in `sight.go`, `reviewer.go`, etc. have godoc comments +- [ ] `CHANGELOG.md` updated under `## [Unreleased]` if user-visible +- [ ] No new false-positive class introduced in eval set +- [ ] SARIF output (if touched) validates against the 2.1.0 schema +- [ ] No secrets, tokens, or PII in fixtures (eval inputs use synthetic + diffs only) +- [ ] No `Co-authored-by:` trailers (this is solo-developer work) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..eeb70b3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - dependencies + - go + commit-message: + prefix: "chore(deps)" + include: scope + groups: + mark3labs-mcp: + patterns: + - "github.com/mark3labs/mcp-go*" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 3 + labels: + - dependencies + - github-actions + commit-message: + prefix: "chore(ci)" + include: scope diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df814e1..8abb3cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,21 @@ +# Canonical CI workflow for hawk-eco Go repos. +# Source of truth: .shared-templates/workflows/go-ci.yml.tmpl +# +# Two deployment models: +# +# 1. NOW — render this template inline into each repo's +# .github/workflows/ci.yml. Every repo has identical content. +# +# 2. LATER — once GrayCodeAI/.github exists as a central repo, move this +# file to GrayCodeAI/.github/.github/workflows/go-ci.yml with +# `on: workflow_call:`. Each repo's ci.yml becomes a 5-line caller: +# +# name: CI +# on: { push: { branches: [main] }, pull_request: } +# jobs: +# ci: +# uses: GrayCodeAI/.github/.github/workflows/go-ci.yml@main + name: CI on: @@ -6,41 +24,138 @@ on: pull_request: branches: [main, dev] +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GO_VERSION: "1.26.1" + jobs: - test: - name: Test + # ------------------------------------------------------------------------- + # Format + vet — fastest, fail fast. + # ------------------------------------------------------------------------- + fmt-vet: + name: fmt + vet runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 with: - go-version: "1.26.1" + go-version: ${{ env.GO_VERSION }} cache: true - - - name: Build - run: go build ./... - - - name: Vet - run: go vet ./... - - - name: Test - run: go test ./... -race -count=1 -timeout=60s - - - name: Coverage + - name: gofumpt diff run: | - go test -coverprofile=coverage.out ./... - go tool cover -func=coverage.out | tail -1 + go install mvdan.cc/gofumpt@latest + out=$(gofumpt -l .) + if [ -n "$out" ]; then + echo "::error::gofumpt would reformat the following files:" + echo "$out" + exit 1 + fi + - name: go vet + run: go vet ./... + # ------------------------------------------------------------------------- + # Lint — golangci-lint covers most static checks. + # ------------------------------------------------------------------------- lint: + name: lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.26.1' + go-version: ${{ env.GO_VERSION }} + cache: true - uses: golangci/golangci-lint-action@v7 with: version: v2.1.0 install-mode: goinstall verify: false + args: --timeout=5m + + # ------------------------------------------------------------------------- + # Tests with race detector + coverage upload. + # ------------------------------------------------------------------------- + test: + name: test (race + cover) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - name: Tidy check + run: | + go mod tidy + if ! git diff --quiet; then + echo "::error::go.mod / go.sum out of date — run 'go mod tidy' and commit" + git diff + exit 1 + fi + - name: Test + run: go test ./... -race -count=1 -coverprofile=coverage.out -covermode=atomic -timeout=180s + - name: Coverage summary + run: go tool cover -func=coverage.out | tail -1 + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.out + + # ------------------------------------------------------------------------- + # Security scan — vulnerability database + (optional) gosec. + # ------------------------------------------------------------------------- + security: + name: security + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - name: govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + - name: gosec (advisory) + continue-on-error: true + run: | + go install github.com/securego/gosec/v2/cmd/gosec@latest + gosec -exclude=G104,G301,G302,G304,G306 ./... + + # ------------------------------------------------------------------------- + # Cross-platform build matrix — only for repos that produce a binary. + # Repos that are pure libraries can keep this job (it'll just `go build ./...`) + # or remove it locally. + # ------------------------------------------------------------------------- + build: + name: build (${{ matrix.goos }}/${{ matrix.goarch }}) + runs-on: ubuntu-latest + needs: [fmt-vet, lint, test] + strategy: + fail-fast: false + matrix: + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + exclude: + - goos: windows + goarch: arm64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: go build ./... diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..639f55f --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,43 @@ +# Canonical release-please workflow for hawk-eco repos. +# Opens / updates a release PR on every push to main; on merge of that PR, +# tags the new release. The tag triggers goreleaser (separate workflow). +# +# Source of truth: .shared-templates/release-please.yml.tmpl at the eco root. + +name: release-please + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + issues: write + +concurrency: + group: release-please-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - name: Run release-please + id: release + uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Summary + if: always() + run: | + if [[ "${{ steps.release.outputs.release_created }}" == "true" ]]; then + echo "Released ${{ steps.release.outputs.tag_name }}." >> $GITHUB_STEP_SUMMARY + elif [[ "${{ steps.release.outputs.pr }}" != "" ]]; then + echo "Updated release PR: ${{ steps.release.outputs.pr }}" >> $GITHUB_STEP_SUMMARY + else + echo "No release-relevant changes detected." >> $GITHUB_STEP_SUMMARY + fi diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..2be9c43 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.2.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index bd2ce97..874c0dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,41 @@ All notable changes to sight are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [Unreleased] + +### Changed +- **Version re-baselined to `0.2.0`** across the MCP server advertisement + and both SARIF driver-version sites. Aligns sight with the rest of + the hawk-eco ecosystem (`hawk`, `tok`, `eyrie`, `yaad`, `trace`, + `inspect`). + - `mcp/server.go`: `mcpserver.NewMCPServer("sight", "0.2.0", ...)` + - `sarif.go`: `Driver.Version`/`Driver.SemanticVersion` → `"0.2.0"` + (the SARIF spec version remains `"2.1.0"` — that's a different + field; it identifies the SARIF format, not the tool) + - `internal/output/sarif.go`: same fix in the duplicated SARIF code + +### Added — Production hygiene (top-50 OSS parity) +- `CODE_OF_CONDUCT.md` — Contributor Covenant 2.1. +- `.gitattributes` — LF normalization, binary detection, GitHub + linguist hints (collapse `go.sum` in PR diffs). +- `.editorconfig` — UTF-8, LF, final newline, trim trailing whitespace, + tabs for Go, 2-space indent for YAML/JSON/TOML. +- `.github/dependabot.yml` — weekly `gomod` and `github-actions` + updates. +- `.github/PULL_REQUEST_TEMPLATE.md` — Summary / Changes / Review- + quality impact (eval-set numbers) / Testing / Checklist. +- `.github/ISSUE_TEMPLATE/bug_report.yml` — structured bug report + with surface dropdown (library API / MCP / SARIF output / eval). +- `.github/ISSUE_TEMPLATE/feature_request.yml` — feature request with + a `kind` selector (review concerns / static rules / SARIF / MCP / + config / eval / output) and solo-dev fit checks. +- `.github/ISSUE_TEMPLATE/config.yml` — routes security to advisories, + questions to discussions, blocks blank issues. + --- ## [0.4.0] — 2026-05-08 diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..8a168a9 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,23 @@ +# CODEOWNERS for sight (AI code review library) +* @GrayCodeAI/maintainers + +# Review pipeline +/reviewer.go @GrayCodeAI/review-team +/sight.go @GrayCodeAI/review-team +/static_rules.go @GrayCodeAI/review-team +/internal/review/ @GrayCodeAI/review-team + +# Output formats (SARIF, etc.) +/internal/output/ @GrayCodeAI/review-team +/sarif.go @GrayCodeAI/review-team + +# Versioning +/VERSION @GrayCodeAI/maintainers +/version.go @GrayCodeAI/maintainers + +# CI / release +/.github/ @GrayCodeAI/devops-team +/Makefile @GrayCodeAI/devops-team + +# Documentation +*.md @GrayCodeAI/docs-team diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2c589a7 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,60 @@ +# Code of Conduct + +## Our pledge + +We — the maintainers and contributors of the sight project — pledge to +make participation in our community a harassment-free experience for everyone, +regardless of age, body size, visible or invisible disability, ethnicity, sex +characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our standards + +Examples of behaviour that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people. +- Being respectful of differing opinions, viewpoints, and experiences. +- Giving and gracefully accepting constructive feedback. +- Accepting responsibility, apologising to those affected by mistakes, and + learning from the experience. +- Focusing on what is best not just for us as individuals, but for the + overall community. + +Examples of unacceptable behaviour: + +- The use of sexualised language or imagery, and sexual attention or advances. +- Trolling, insulting or derogatory comments, and personal or political + attacks. +- Public or private harassment. +- Publishing others' private information, such as a physical or email + address, without their explicit permission. +- Other conduct which could reasonably be considered inappropriate in a + professional setting. + +## Enforcement + +Community leaders are responsible for clarifying and enforcing our standards +of acceptable behaviour, and will take appropriate and fair corrective +action in response to any behaviour they deem inappropriate, threatening, +offensive, or harmful. + +Instances of abusive, harassing, or otherwise unacceptable behaviour may be +reported to the maintainers via the contact in `SECURITY.md` or by opening a +confidential GitHub Security Advisory at +. All +complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of +the reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). + +For answers to common questions about this code of conduct, see the +Contributor Covenant FAQ at . diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c07e301..a893ebb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,39 +1,114 @@ # Contributing to sight -## Setup +Thanks for your interest! This guide covers the conventions used across the +hawk-eco. The eco-wide standards (versioning, release tooling, repo layout) +are defined in . -```bash -git clone https://github.com/GrayCodeAI/sight.git -cd sight -make test +## Quick start + +1. Fork the repo and create a feature branch off `main`: + ```bash + git checkout -b feat/short-description + ``` +2. Make your changes in small, focused commits. +3. Run the full local check before pushing: + ```bash + make ci + ``` +4. Open a pull request. CI will re-run the same checks plus security + scanning, race-detector tests, and (where applicable) integration tests. + +## Build & test + +This repo uses the standardised hawk-eco Makefile targets. Run `make help` +for the full list. The most common targets: + +| Target | What it does | +| ------------------- | ------------------------------------------------ | +| `make build` | Build the binary / verify the library compiles | +| `make test` | Run unit tests | +| `make test-race` | Run unit tests with the race detector | +| `make cover` | Generate a coverage report | +| `make lint` | Run the linter (`golangci-lint` / `ruff`) | +| `make fmt` | Format source files | +| `make vet` | Run `go vet` / `mypy` | +| `make security` | Run `govulncheck` / `pip-audit` | +| `make ci` | Run everything CI runs (the gate before pushing) | + +## Commit message convention + +We use [Conventional Commits](https://www.conventionalcommits.org/). This +isn't cosmetic — release-please reads commit messages to bump the `VERSION` +file and generate the CHANGELOG, so getting them right matters. + +``` +(): + + + + ``` -## Requirements +**Types:** -- Go 1.26+ -- No external dependencies beyond mcp-go +- `feat:` — a new feature (triggers a minor version bump) +- `fix:` — a bug fix (triggers a patch version bump) +- `perf:` — performance improvement +- `refactor:` — code restructure with no behaviour change +- `docs:` — documentation only +- `test:` — adding or fixing tests +- `build:` — build system or dependencies +- `ci:` — CI configuration +- `chore:` — anything else (no release effect) +- `revert:` — reverts a previous commit -## Development +**Breaking changes:** add `!` after the type/scope or include `BREAKING +CHANGE:` in the footer. This triggers a major version bump. -```bash -make test # Run tests -make test-race # With race detector -make cover # Coverage report -make lint # Static analysis -make bench # Benchmarks +Examples: + +``` +feat(client): add streaming retry with exponential backoff +fix: handle empty response body in chat handler +refactor!: rename ClientV1 to Client (BREAKING CHANGE) ``` -## Guidelines +## Pull request checklist + +Before requesting review: + +- [ ] `make ci` passes locally. +- [ ] New behaviour has tests; bug fixes have a regression test. +- [ ] `CHANGELOG.md` entries are **not** edited manually — release-please + generates them from your commit messages. +- [ ] The `VERSION` file is **not** edited manually — release-please bumps + it on release. +- [ ] Public API changes have updated doc comments. +- [ ] No secrets, API keys, or PII in code, comments, tests, or fixtures. + +## Code review etiquette + +- Reviewers focus on correctness, design, and tests; formatting is + enforced by tooling, not humans. +- Authors respond to every comment (resolved, addressed, or politely + declined with rationale) — no silent dismissals. +- Squash-merge by default; the PR title becomes the commit (so it must + be a valid Conventional Commit message). +- One approving review from a CODEOWNERS-listed reviewer is required. + +## Reporting bugs + +Open an issue using the bug-report template. Include the `sight` +version (`sight --version` for binaries, `sight.Version` for +libraries — see this repo's `VERSION` file), reproduction steps, expected +behaviour, and actual behaviour. + +## Reporting security issues -- All tests must pass with `-race` flag -- Use `t.Parallel()` in tests that don't share state -- Provider interface changes require discussion first -- Add tests for new functionality -- Follow existing patterns (functional options, internal packages) +**Do not open a public issue.** See [SECURITY.md](./SECURITY.md) for +private reporting channels. -## Pull Requests +## License -1. Open an issue first for significant changes -2. Run `make all` before submitting (vet + test + build) -3. Include test coverage for new code -4. Update CHANGELOG.md +By contributing, you agree that your contributions will be licensed under +the same license as this repo (see [LICENSE](./LICENSE)). diff --git a/Makefile b/Makefile index f45c4cf..8efddb7 100644 --- a/Makefile +++ b/Makefile @@ -1,31 +1,112 @@ -.PHONY: all build test test-race cover vet lint bench clean +# Canonical hawk-eco Makefile for Go library repos. +# Source of truth: .shared-templates/Makefile.library.tmpl at the eco root. +# Placeholders rendered per repo: sight. -all: vet test build +# --------------------------------------------------------------------------- +# Project metadata +# --------------------------------------------------------------------------- +NAME := sight -build: - go build ./... +# --------------------------------------------------------------------------- +# Versioning — sourced from VERSION file; falls back to git describe. +# See https://github.com/GrayCodeAI/hawk/blob/main/VERSIONING.md. +# --------------------------------------------------------------------------- +VERSION ?= $(shell cat VERSION 2>/dev/null | head -n1 | tr -d '[:space:]' || git describe --tags --always --dirty 2>/dev/null || echo "dev") +COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "none") +DATE := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') -test: - go test ./... -timeout 60s +# --------------------------------------------------------------------------- +# Tooling — pinned, install if missing. +# --------------------------------------------------------------------------- +GOBIN_DIR := $(shell go env GOPATH)/bin +GOLANGCI := $(GOBIN_DIR)/golangci-lint +GOFUMPT := $(GOBIN_DIR)/gofumpt +GOIMPORTS := $(GOBIN_DIR)/goimports +GOVULNCHECK := $(GOBIN_DIR)/govulncheck -test-race: - go test -race ./... -timeout 60s +# --------------------------------------------------------------------------- +# Phony declarations (alphabetical). +# --------------------------------------------------------------------------- +.PHONY: all bench build ci clean cover fmt help lint lint-fix \ + security test test-10x test-race tidy version vet -cover: - go test -coverprofile=coverage.out ./... - go tool cover -func=coverage.out - @rm -f coverage.out +# --------------------------------------------------------------------------- +# Default target. +# --------------------------------------------------------------------------- +all: lint test build ## Default — lint, test, build. -vet: +# --------------------------------------------------------------------------- +# Build (verify the library compiles). +# --------------------------------------------------------------------------- +build: ## Verify the library compiles. + CGO_ENABLED=0 go build ./... + +# --------------------------------------------------------------------------- +# Tests. +# --------------------------------------------------------------------------- +test: ## Run unit tests. + go test ./... -count=1 -timeout=120s + +test-race: ## Run unit tests with the race detector. + go test ./... -race -count=1 -timeout=180s + +test-10x: ## Run tests 10 times to surface flakes. + go test ./... -race -count=10 -timeout=600s + +cover: ## Generate a coverage report (coverage.out + coverage.html). + go test ./... -race -coverprofile=coverage.out -covermode=atomic -timeout=180s + @go tool cover -func=coverage.out | grep "^total:" + @go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report: coverage.html" + +bench: ## Run benchmarks. + go test ./... -bench=. -benchmem -count=3 -timeout=300s + +# --------------------------------------------------------------------------- +# Quality gates. +# --------------------------------------------------------------------------- +fmt: ## Format source files (gofumpt + goimports). + @command -v $(GOFUMPT) >/dev/null 2>&1 || (echo "install: go install mvdan.cc/gofumpt@latest" && exit 1) + @command -v $(GOIMPORTS) >/dev/null 2>&1 || (echo "install: go install golang.org/x/tools/cmd/goimports@latest" && exit 1) + $(GOFUMPT) -w . + $(GOIMPORTS) -w . + +vet: ## Run go vet. go vet ./... -lint: - @which staticcheck > /dev/null 2>&1 || (echo "install: go install honnef.co/go/tools/cmd/staticcheck@latest" && exit 1) - staticcheck ./... +lint: ## Run golangci-lint. + @command -v $(GOLANGCI) >/dev/null 2>&1 || (echo "install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest" && exit 1) + $(GOLANGCI) run ./... --timeout=5m + +lint-fix: ## Run golangci-lint with --fix. + @command -v $(GOLANGCI) >/dev/null 2>&1 || (echo "install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest" && exit 1) + $(GOLANGCI) run ./... --fix --timeout=5m + +security: ## Run govulncheck. + @command -v $(GOVULNCHECK) >/dev/null 2>&1 || (echo "install: go install golang.org/x/vuln/cmd/govulncheck@latest" && exit 1) + $(GOVULNCHECK) ./... + +tidy: ## Tidy go.mod / go.sum. + go mod tidy + go mod verify + +# --------------------------------------------------------------------------- +# Composite gate used by CI and pre-push. +# --------------------------------------------------------------------------- +ci: tidy fmt vet lint test-race security ## Run everything CI runs. + @echo "All CI checks passed." + +# --------------------------------------------------------------------------- +# Misc. +# --------------------------------------------------------------------------- +version: ## Print the version that will be embedded. + @echo "Version: $(VERSION)" + @echo "Commit: $(COMMIT)" + @echo "Date: $(DATE)" -bench: - go test -bench=. -benchmem ./... +clean: ## Remove build artefacts. + rm -rf coverage.out coverage.html + go clean -testcache -clean: - go clean - rm -f coverage.out +help: ## Show this help. + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' diff --git a/SECURITY.md b/SECURITY.md index e9336d7..1dd2373 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,27 +1,71 @@ -# Security Policy +# Security Policy — sight -## Supported Versions +## Supported versions -| Version | Supported | -|---------|-----------| -| 0.4.x | Yes | -| 0.2.x | Yes | -| < 0.2 | No | +We support the latest minor version on each `0.x` line, and the latest two +minor versions once `1.x` ships. Older versions receive critical-severity +fixes only on a best-effort basis. -## Reporting a Vulnerability +The current canonical version is the contents of the [`VERSION`](./VERSION) +file at the repo root. See [`VERSIONING.md`](https://github.com/GrayCodeAI/hawk/blob/main/VERSIONING.md) +for the eco-wide versioning scheme. -**Do NOT open a public GitHub issue for security vulnerabilities.** +## Reporting a vulnerability -Email: security@graycode.ai +**Do not open a public GitHub issue for security vulnerabilities.** Instead: -### Response Timeline -- Acknowledgment: 48 hours -- Initial assessment: 5 business days -- Fix: 7-30 days depending on severity +1. Open a private [GitHub Security Advisory](https://github.com/GrayCodeAI/sight/security/advisories/new), **or** +2. Email `security@graycode.ai` with the details below. -## Security Considerations +Include in your report: -- sight is a library — it does not make network calls itself -- LLM provider calls are made by the consumer (via Provider interface) -- Diff content is passed to the LLM; consumers are responsible for redacting secrets -- No credentials are stored or transmitted by sight itself +- A description of the vulnerability and the affected component. +- Steps to reproduce, ideally with a minimal proof-of-concept. +- The version (`VERSION` file or git SHA) you tested against. +- The potential impact and any suggested mitigation. + +**Response targets:** + +- Initial acknowledgement: within **48 hours**. +- Triage and severity assessment: within **5 business days**. +- Coordinated fix and disclosure: within **30 days** for high/critical, **90 + days** for medium/low (per industry-standard responsible disclosure). + +## Disclosure policy + +We follow [coordinated vulnerability disclosure](https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure): + +- Reporters receive credit in the advisory and CHANGELOG (unless they opt + out). +- We request that reporters refrain from public disclosure until a fix has + been released or the disclosure deadline above has elapsed. +- We will not pursue legal action against good-faith researchers acting + within this policy. + +## Security practices in this repo + +- **Dependency monitoring:** automated via Dependabot (see + `.github/dependabot.yml`). +- **Static analysis:** `golangci-lint` / `ruff` / `mypy` enforced in CI. +- **Vulnerability scanning:** `govulncheck` (Go) / `pip-audit` (Python) run + on every CI build. +- **Lockfiles:** `go.sum` / `pnpm-lock.yaml` / `pyproject.toml` are pinned + and committed. +- **Reproducible builds:** release artefacts ship with SHA-256 checksums via + goreleaser. +- **No secrets in source:** API keys are configuration, not constants. Pre- + commit hooks block accidental secret commits. + +## Scope + +This policy covers the code in this repository and the release artefacts +published from it. It does not cover: + +- Third-party dependencies (report to upstream). +- LLM provider services that sight integrates with (report to the + provider). +- Local filesystem misuse where an attacker already has shell access (out of + threat model). + +For sight-specific threat-model notes, see the README and any docs in +this repo. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.2.0 diff --git a/go.mod b/go.mod index b5d344b..bf3052f 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/GrayCodeAI/sight go 1.26.1 -require github.com/mark3labs/mcp-go v0.49.0 +require ( + github.com/GrayCodeAI/hawk/sarif v0.1.0 + github.com/mark3labs/mcp-go v0.49.0 +) require ( github.com/google/jsonschema-go v0.4.2 // indirect @@ -10,3 +13,9 @@ require ( github.com/spf13/cast v1.7.1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect ) + +// TODO(release): remove this `replace` once github.com/GrayCodeAI/hawk is +// pushed and the hawk/sarif sub-module is reachable upstream. While the +// module lives only in the local hawk-eco workspace, the replace points at +// it so `go build` works without the upstream existing. +replace github.com/GrayCodeAI/hawk/sarif => ../hawk/sarif diff --git a/internal/output/sarif.go b/internal/output/sarif.go index f118650..5fc9c80 100644 --- a/internal/output/sarif.go +++ b/internal/output/sarif.go @@ -1,200 +1,95 @@ +// SARIF 2.1.0 output for sight, emitted via the shared +// github.com/GrayCodeAI/hawk/sarif package. + package output import ( - "encoding/json" - "fmt" -) - -// SARIF types per SARIF 2.1.0 specification. - -type SARIFLog struct { - Schema string `json:"$schema"` - Version string `json:"version"` - Runs []SARIFRun `json:"runs"` -} - -type SARIFRun struct { - Tool SARIFTool `json:"tool"` - Results []SARIFResult `json:"results"` -} - -type SARIFTool struct { - Driver SARIFDriver `json:"driver"` -} - -type SARIFDriver struct { - Name string `json:"name"` - Version string `json:"version"` - InformationURI string `json:"informationUri"` - Rules []SARIFRule `json:"rules,omitempty"` -} - -type SARIFRule struct { - ID string `json:"id"` - Name string `json:"name"` - ShortDescription SARIFMultiformat `json:"shortDescription"` - DefaultConfig *SARIFRuleConfig `json:"defaultConfiguration,omitempty"` -} - -type SARIFRuleConfig struct { - Level string `json:"level"` -} - -type SARIFMultiformat struct { - Text string `json:"text"` -} - -type SARIFResult struct { - RuleID string `json:"ruleId"` - Level string `json:"level"` - Message SARIFMultiformat `json:"message"` - Locations []SARIFLocation `json:"locations,omitempty"` - Fixes []SARIFFix `json:"fixes,omitempty"` - Taxa []SARIFTaxaReference `json:"taxa,omitempty"` -} - -// SARIFTaxaReference references an external taxonomy entry (e.g., CWE). -type SARIFTaxaReference struct { - ID string `json:"id"` - ToolComponent SARIFMultiformat `json:"toolComponent"` -} - -type SARIFLocation struct { - PhysicalLocation SARIFPhysicalLocation `json:"physicalLocation"` -} - -type SARIFPhysicalLocation struct { - ArtifactLocation SARIFArtifactLocation `json:"artifactLocation"` - Region *SARIFRegion `json:"region,omitempty"` -} - -type SARIFArtifactLocation struct { - URI string `json:"uri"` -} - -type SARIFRegion struct { - StartLine int `json:"startLine,omitempty"` - EndLine int `json:"endLine,omitempty"` - StartColumn int `json:"startColumn,omitempty"` -} - -type SARIFFix struct { - Description SARIFMultiformat `json:"description"` - Changes []SARIFArtifactChange `json:"artifactChanges"` -} + "strings" -type SARIFArtifactChange struct { - ArtifactLocation SARIFArtifactLocation `json:"artifactLocation"` - Replacements []SARIFReplacement `json:"replacements"` -} + "github.com/GrayCodeAI/hawk/sarif" +) -type SARIFReplacement struct { - DeletedRegion SARIFRegion `json:"deletedRegion"` - InsertedContent *SARIFInsertedContent `json:"insertedContent,omitempty"` -} +// ToolVersion is the sight tool version reported in SARIF output. It is set +// at startup by the parent sight package from the canonical VERSION file. +// Default falls back to "dev" so direct internal use (e.g. tests) still works. +var ToolVersion = "dev" -type SARIFInsertedContent struct { - Text string `json:"text"` -} +// SetToolVersion lets the parent sight package wire its canonical Version +// into this internal package without creating an import cycle. +func SetToolVersion(v string) { ToolVersion = v } // FormatSARIF produces SARIF 2.1.0 JSON from review findings. +// +// Output is delegated to the shared sarif.Builder so sight and inspect +// produce structurally identical SARIF. func FormatSARIF(findings []Finding) (string, error) { - rules := buildSARIFRules(findings) - results := make([]SARIFResult, 0, len(findings)) + b := sarif.New(sarif.Tool{ + Name: "sight", + Version: ToolVersion, + InformationURI: "https://github.com/GrayCodeAI/sight", + }) for _, f := range findings { - result := SARIFResult{ - RuleID: fmt.Sprintf("sight/%s", f.Concern), - Level: sarifLevel(f.Severity), - Message: SARIFMultiformat{Text: f.Message}, + ruleID := "sight/" + f.Concern + + // Register the rule (deduped by ID inside the builder). + rule := sarif.Rule{ + ID: ruleID, + Name: f.Concern, + ShortDescription: f.Concern + " analysis", + Severity: severityToSARIF(f.Severity), + } + if f.CWE != "" { + rule.Tags = []string{"security", f.CWE} + rule.HelpURI = "https://cwe.mitre.org/data/definitions/" + + strings.TrimPrefix(f.CWE, "CWE-") + ".html" } + b.AddRule(rule) + // Add the result. + result := sarif.Result{ + RuleID: ruleID, + Severity: severityToSARIF(f.Severity), + Message: f.Message, + } if f.File != "" { - loc := SARIFLocation{ - PhysicalLocation: SARIFPhysicalLocation{ - ArtifactLocation: SARIFArtifactLocation{URI: f.File}, - }, - } + result.URI = f.File if f.Line > 0 { - loc.PhysicalLocation.Region = &SARIFRegion{ + result.Region = &sarif.Region{ StartLine: f.Line, EndLine: f.EndLine, } - if loc.PhysicalLocation.Region.EndLine == 0 { - loc.PhysicalLocation.Region.EndLine = f.Line - } } - result.Locations = append(result.Locations, loc) } - if f.Fix != "" { - result.Fixes = append(result.Fixes, SARIFFix{ - Description: SARIFMultiformat{Text: f.Fix}, - }) + result.Fix = f.Fix } - if f.CWE != "" { - result.Taxa = append(result.Taxa, SARIFTaxaReference{ - ID: f.CWE, - ToolComponent: SARIFMultiformat{Text: "CWE"}, - }) + result.Taxa = []sarif.TaxaRef{ + {ID: f.CWE, Component: "CWE"}, + } } - - results = append(results, result) - } - - log := SARIFLog{ - Schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", - Version: "2.1.0", - Runs: []SARIFRun{ - { - Tool: SARIFTool{ - Driver: SARIFDriver{ - Name: "sight", - Version: "1.0.0", - InformationURI: "https://github.com/GrayCodeAI/sight", - Rules: rules, - }, - }, - Results: results, - }, - }, - } - - out, err := json.MarshalIndent(log, "", " ") - if err != nil { - return "", err + b.AddResult(result) } - return string(out), nil -} -func buildSARIFRules(findings []Finding) []SARIFRule { - seen := make(map[string]bool) - var rules []SARIFRule - - for _, f := range findings { - id := fmt.Sprintf("sight/%s", f.Concern) - if seen[id] { - continue - } - seen[id] = true - rules = append(rules, SARIFRule{ - ID: id, - Name: f.Concern, - ShortDescription: SARIFMultiformat{Text: f.Concern + " analysis"}, - DefaultConfig: &SARIFRuleConfig{Level: sarifLevel(f.Severity)}, - }) - } - return rules + return b.String(), nil } -func sarifLevel(severity int) string { +// severityToSARIF maps the int severity used internally by sight to the +// sarif.Severity enum. +// +// sight Finding.Severity convention (int): +// +// >= 3 (high, critical) → SARIF "error" +// 2 (medium) → SARIF "warning" +// other (0, 1, info) → SARIF "note" +func severityToSARIF(severity int) sarif.Severity { switch { - case severity >= 3: // high, critical - return "error" - case severity == 2: // medium - return "warning" - default: // low, info - return "note" + case severity >= 3: + return sarif.SeverityError + case severity == 2: + return sarif.SeverityWarning + default: + return sarif.SeverityNote } } diff --git a/internal/output/sarif_test.go b/internal/output/sarif_test.go index 33d92bb..7b24e67 100644 --- a/internal/output/sarif_test.go +++ b/internal/output/sarif_test.go @@ -1,3 +1,11 @@ +// Tests for SARIF emission. SARIF output is delegated to the shared +// github.com/GrayCodeAI/sarif package, which has its own structural tests; +// the tests here verify sight-specific behaviour (severity mapping, rule +// IDs, CWE handling, location population). +// +// Tests parse the JSON output via local minimal types rather than +// re-exporting the wire format. + package output import ( @@ -6,17 +14,59 @@ import ( "testing" ) +// Minimal SARIF wire model — covers only the fields the tests assert on. +type testSarifLog struct { + Runs []struct { + Tool struct { + Driver struct { + Name string + Rules []struct { + ID string + Name string + } + } + } + Results []struct { + RuleID string `json:"ruleId"` + Level string + Message struct{ Text string } + Locations []struct { + PhysicalLocation struct { + ArtifactLocation struct{ URI string } `json:"artifactLocation"` + Region *struct { + StartLine int `json:"startLine"` + EndLine int `json:"endLine"` + } + } `json:"physicalLocation"` + } + Fixes []struct { + Description struct{ Text string } + } + Taxa []struct { + ID string + ToolComponent struct{ Text string } + } + } + } + Version string +} + +func parseSARIF(t *testing.T, raw string) testSarifLog { + t.Helper() + var log testSarifLog + if err := json.Unmarshal([]byte(raw), &log); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, raw) + } + return log +} + func TestFormatSARIF_EmptyFindings(t *testing.T) { out, err := FormatSARIF(nil) if err != nil { t.Fatalf("FormatSARIF error: %v", err) } - // Verify valid JSON - var log SARIFLog - if err := json.Unmarshal([]byte(out), &log); err != nil { - t.Fatalf("invalid JSON output: %v", err) - } + log := parseSARIF(t, out) if log.Version != "2.1.0" { t.Errorf("expected version 2.1.0, got %q", log.Version) @@ -35,21 +85,15 @@ func TestFormatSARIF_EmptyFindings(t *testing.T) { func TestFormatSARIF_WithFindings(t *testing.T) { findings := []Finding{ { - Concern: "security", - Severity: 4, // critical - File: "handler.go", - Line: 13, - EndLine: 14, - Message: "SQL injection via string concatenation", - Fix: "Use parameterized queries", - Reasoning: "User input directly in SQL", + Concern: "security", Severity: 4, + File: "handler.go", Line: 13, EndLine: 14, + Message: "SQL injection via string concatenation", + Fix: "Use parameterized queries", }, { - Concern: "bugs", - Severity: 3, // high - File: "util.go", - Line: 25, - Message: "Nil pointer dereference", + Concern: "bugs", Severity: 3, + File: "util.go", Line: 25, + Message: "Nil pointer dereference", }, } @@ -57,17 +101,12 @@ func TestFormatSARIF_WithFindings(t *testing.T) { if err != nil { t.Fatalf("FormatSARIF error: %v", err) } - - var log SARIFLog - if err := json.Unmarshal([]byte(out), &log); err != nil { - t.Fatalf("invalid JSON: %v", err) - } + log := parseSARIF(t, out) if len(log.Runs[0].Results) != 2 { t.Fatalf("expected 2 results, got %d", len(log.Runs[0].Results)) } - // Check first result r0 := log.Runs[0].Results[0] if r0.RuleID != "sight/security" { t.Errorf("expected ruleId 'sight/security', got %q", r0.RuleID) @@ -100,7 +139,6 @@ func TestFormatSARIF_WithFindings(t *testing.T) { t.Errorf("unexpected fix: %q", r0.Fixes[0].Description.Text) } - // Check second result r1 := log.Runs[0].Results[1] if r1.RuleID != "sight/bugs" { t.Errorf("expected ruleId 'sight/bugs', got %q", r1.RuleID) @@ -116,12 +154,10 @@ func TestFormatSARIF_WithFindings(t *testing.T) { func TestFormatSARIF_WithCWE(t *testing.T) { findings := []Finding{ { - Concern: "security", - Severity: 4, - File: "handler.go", - Line: 10, - Message: "SQL injection", - CWE: "CWE-89", + Concern: "security", Severity: 4, + File: "handler.go", Line: 10, + Message: "SQL injection", + CWE: "CWE-89", }, } @@ -129,11 +165,7 @@ func TestFormatSARIF_WithCWE(t *testing.T) { if err != nil { t.Fatalf("FormatSARIF error: %v", err) } - - var log SARIFLog - if err := json.Unmarshal([]byte(out), &log); err != nil { - t.Fatalf("invalid JSON: %v", err) - } + log := parseSARIF(t, out) result := log.Runs[0].Results[0] if len(result.Taxa) != 1 { @@ -149,22 +181,18 @@ func TestFormatSARIF_WithCWE(t *testing.T) { func TestFormatSARIF_SeverityLevels(t *testing.T) { findings := []Finding{ - {Concern: "a", Severity: 4, File: "a.go", Line: 1, Message: "critical"}, // error - {Concern: "b", Severity: 3, File: "b.go", Line: 1, Message: "high"}, // error - {Concern: "c", Severity: 2, File: "c.go", Line: 1, Message: "medium"}, // warning - {Concern: "d", Severity: 1, File: "d.go", Line: 1, Message: "low"}, // note - {Concern: "e", Severity: 0, File: "e.go", Line: 1, Message: "info"}, // note + {Concern: "a", Severity: 4, File: "a.go", Line: 1, Message: "critical"}, + {Concern: "b", Severity: 3, File: "b.go", Line: 1, Message: "high"}, + {Concern: "c", Severity: 2, File: "c.go", Line: 1, Message: "medium"}, + {Concern: "d", Severity: 1, File: "d.go", Line: 1, Message: "low"}, + {Concern: "e", Severity: 0, File: "e.go", Line: 1, Message: "info"}, } out, err := FormatSARIF(findings) if err != nil { t.Fatalf("FormatSARIF error: %v", err) } - - var log SARIFLog - if err := json.Unmarshal([]byte(out), &log); err != nil { - t.Fatalf("invalid JSON: %v", err) - } + log := parseSARIF(t, out) expected := []string{"error", "error", "warning", "note", "note"} for i, r := range log.Runs[0].Results { @@ -176,49 +204,32 @@ func TestFormatSARIF_SeverityLevels(t *testing.T) { func TestFormatSARIF_NoFile(t *testing.T) { findings := []Finding{ - { - Concern: "security", - Severity: 2, - Message: "General security concern", - }, + {Concern: "security", Severity: 2, Message: "General security concern"}, } out, err := FormatSARIF(findings) if err != nil { t.Fatalf("FormatSARIF error: %v", err) } + log := parseSARIF(t, out) - var log SARIFLog - if err := json.Unmarshal([]byte(out), &log); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - - result := log.Runs[0].Results[0] - if len(result.Locations) != 0 { - t.Errorf("expected 0 locations for finding without file, got %d", len(result.Locations)) + if len(log.Runs[0].Results[0].Locations) != 0 { + t.Errorf("expected 0 locations for finding without file, got %d", + len(log.Runs[0].Results[0].Locations)) } } func TestFormatSARIF_NoLineNumber(t *testing.T) { findings := []Finding{ - { - Concern: "security", - Severity: 2, - File: "handler.go", - Line: 0, // no line number - Message: "File-level concern", - }, + {Concern: "security", Severity: 2, File: "handler.go", Line: 0, + Message: "File-level concern"}, } out, err := FormatSARIF(findings) if err != nil { t.Fatalf("FormatSARIF error: %v", err) } - - var log SARIFLog - if err := json.Unmarshal([]byte(out), &log); err != nil { - t.Fatalf("invalid JSON: %v", err) - } + log := parseSARIF(t, out) result := log.Runs[0].Results[0] if len(result.Locations) != 1 { @@ -230,29 +241,21 @@ func TestFormatSARIF_NoLineNumber(t *testing.T) { } func TestFormatSARIF_EndLineFallback(t *testing.T) { - // When EndLine is 0, it should default to StartLine findings := []Finding{ - { - Concern: "bugs", - Severity: 2, - File: "main.go", - Line: 42, - EndLine: 0, - Message: "Issue", - }, + {Concern: "bugs", Severity: 2, File: "main.go", Line: 42, EndLine: 0, + Message: "Issue"}, } out, err := FormatSARIF(findings) if err != nil { t.Fatalf("FormatSARIF error: %v", err) } - - var log SARIFLog - if err := json.Unmarshal([]byte(out), &log); err != nil { - t.Fatalf("invalid JSON: %v", err) - } + log := parseSARIF(t, out) region := log.Runs[0].Results[0].Locations[0].PhysicalLocation.Region + if region == nil { + t.Fatal("expected non-nil region") + } if region.EndLine != 42 { t.Errorf("expected endLine to fallback to 42, got %d", region.EndLine) } @@ -269,13 +272,8 @@ func TestFormatSARIF_RulesDedup(t *testing.T) { if err != nil { t.Fatalf("FormatSARIF error: %v", err) } + log := parseSARIF(t, out) - var log SARIFLog - if err := json.Unmarshal([]byte(out), &log); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - - // Should have 2 rules (security, bugs) not 3 rules := log.Runs[0].Tool.Driver.Rules if len(rules) != 2 { t.Errorf("expected 2 deduplicated rules, got %d", len(rules)) @@ -284,15 +282,14 @@ func TestFormatSARIF_RulesDedup(t *testing.T) { func TestFormatSARIF_ValidJSON(t *testing.T) { findings := []Finding{ - {Concern: "security", Severity: 4, File: "a.go", Line: 1, Message: "test with \"quotes\" and special "}, + {Concern: "security", Severity: 4, File: "a.go", Line: 1, + Message: "test with \"quotes\" and special "}, } out, err := FormatSARIF(findings) if err != nil { t.Fatalf("FormatSARIF error: %v", err) } - - // Should be valid JSON if !json.Valid([]byte(out)) { t.Error("output is not valid JSON") } @@ -308,7 +305,7 @@ func TestFormatSARIF_SchemaURL(t *testing.T) { } } -func TestSarifLevel(t *testing.T) { +func TestSeverityToSARIF(t *testing.T) { tests := []struct { severity int expected string @@ -322,9 +319,17 @@ func TestSarifLevel(t *testing.T) { } for _, tc := range tests { - result := sarifLevel(tc.severity) - if result != tc.expected { - t.Errorf("sarifLevel(%d): expected %q, got %q", tc.severity, tc.expected, result) + got := severityToSARIF(tc.severity) + // Compare against the SARIF wire-level string by emitting a + // single-result builder and reading the level out. + findings := []Finding{ + {Concern: "x", Severity: tc.severity, File: "f", Line: 1, Message: "m"}, + } + out, _ := FormatSARIF(findings) + log := parseSARIF(t, out) + if log.Runs[0].Results[0].Level != tc.expected { + t.Errorf("severityToSARIF(%d) → %v; expected level %q", + tc.severity, got, tc.expected) } } } diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..ba5700d --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,112 @@ +# Canonical lefthook config for hawk-eco Go repos. +# Source of truth: .shared-templates/lefthook.yml.tmpl +# +# Install lefthook: +# brew install lefthook (macOS) +# go install github.com/evilmartians/lefthook@latest +# npm install -g lefthook (cross-platform) +# +# Activate hooks in this repo (one time): +# lefthook install +# +# Skip hooks for a single commit (use sparingly): +# LEFTHOOK=0 git commit -m "..." + +# --------------------------------------------------------------------------- +# pre-commit — runs before commit creation, on staged files only. +# --------------------------------------------------------------------------- +pre-commit: + parallel: true + commands: + + fmt: + glob: "*.go" + run: | + if ! command -v gofumpt >/dev/null 2>&1; then + echo "lefthook: gofumpt not installed (go install mvdan.cc/gofumpt@latest)"; exit 1 + fi + gofumpt -w {staged_files} + stage_fixed: true + + imports: + glob: "*.go" + run: | + if ! command -v goimports >/dev/null 2>&1; then + echo "lefthook: goimports not installed (go install golang.org/x/tools/cmd/goimports@latest)"; exit 1 + fi + goimports -w {staged_files} + stage_fixed: true + + lint: + glob: "*.go" + run: | + if ! command -v golangci-lint >/dev/null 2>&1; then + echo "lefthook: golangci-lint not installed — skipping (install: https://golangci-lint.run/usage/install/)" + exit 0 + fi + golangci-lint run --new-from-rev=HEAD~1 --fix {staged_files} + stage_fixed: true + + yaml-lint: + glob: "*.{yml,yaml}" + run: | + # Quick syntax check via Python's yaml module (already on most systems). + for f in {staged_files}; do + python3 -c "import sys, yaml; yaml.safe_load(open(sys.argv[1]))" "$f" || exit 1 + done + + forbidden-strings: + run: | + # Catch obvious credential-shaped strings in staged additions. + bad=$(git diff --cached --diff-filter=AM -U0 -- {staged_files} \ + | grep -E '^\+' \ + | grep -Ei '(aws_secret|password\s*=|api[_-]?key\s*=|BEGIN [A-Z]+ PRIVATE KEY)' \ + | grep -v 'example\|placeholder\|TODO\|x-release-please' || true) + if [ -n "$bad" ]; then + echo "lefthook: possible secret in staged changes:" + echo "$bad" + echo "If this is a false positive, bypass with: LEFTHOOK=0 git commit" + exit 1 + fi + +# --------------------------------------------------------------------------- +# pre-push — heavier checks, runs only on push (not every commit). +# --------------------------------------------------------------------------- +pre-push: + commands: + + test: + run: go test ./... -count=1 -timeout=60s + + vet: + run: go vet ./... + + govulncheck: + run: | + if ! command -v govulncheck >/dev/null 2>&1; then + echo "lefthook: govulncheck not installed — skipping" + exit 0 + fi + govulncheck ./... + +# --------------------------------------------------------------------------- +# commit-msg — validate Conventional Commits (release-please depends on it). +# --------------------------------------------------------------------------- +commit-msg: + commands: + + conventional-commit: + run: | + msg=$(head -n1 "{1}") + # Allow merge commits, revert commits, and release-please bot commits to bypass. + case "$msg" in + "Merge "*|"Revert "*|"chore(main): release"*) exit 0 ;; + esac + # Conventional commits regex. + if ! echo "$msg" | grep -qE '^(feat|fix|perf|refactor|test|docs|build|ci|chore|revert|style)(\([a-z0-9 _-]+\))?!?: .{1,72}$'; then + echo "lefthook: commit message does not follow Conventional Commits." + echo " format: (): " + echo " example: feat(client): add streaming retry" + echo " full guide: https://www.conventionalcommits.org/" + exit 1 + fi diff --git a/mcp/server.go b/mcp/server.go index 2fc1fa1..37c2675 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -26,7 +26,7 @@ func New(provider sight.Provider, opts ...sight.Option) *Server { provider: provider, opts: opts, } - s.server = mcpserver.NewMCPServer("sight", "0.1.0", + s.server = mcpserver.NewMCPServer("sight", "0.2.0", mcpserver.WithToolCapabilities(true), ) s.registerTools() diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..0750380 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "go", + "package-name": "sight", + "include-v-in-tag": true, + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance" }, + { "type": "refactor", "section": "Refactoring" }, + { "type": "revert", "section": "Reverts" }, + { "type": "docs", "section": "Documentation", "hidden": false }, + { "type": "test", "section": "Tests", "hidden": false }, + { "type": "build", "section": "Build", "hidden": true }, + { "type": "ci", "section": "CI", "hidden": true }, + { "type": "chore", "section": "Chores", "hidden": true }, + { "type": "style", "section": "Style", "hidden": true } + ], + "extra-files": [{"type":"version-txt","path":"VERSION"}] + } + } +} diff --git a/sarif.go b/sarif.go index 1fbe014..1e55cfc 100644 --- a/sarif.go +++ b/sarif.go @@ -1,216 +1,80 @@ package sight import ( - "encoding/json" "strings" -) - -// SARIF 2.1.0 output support. -// This enables integration with GitHub Code Scanning, VS Code SARIF Viewer, -// and other tools that consume the OASIS SARIF standard. - -// sarifLog is the top-level SARIF 2.1.0 structure. -type sarifLog struct { - Schema string `json:"$schema"` - Version string `json:"version"` - Runs []sarifRun `json:"runs"` -} - -// sarifRun represents a single analysis run. -type sarifRun struct { - Tool sarifTool `json:"tool"` - Results []sarifResult `json:"results"` -} - -// sarifTool describes the analysis tool. -type sarifTool struct { - Driver sarifDriver `json:"driver"` -} - -// sarifDriver describes the tool driver (primary component). -type sarifDriver struct { - Name string `json:"name"` - Version string `json:"version"` - InformationURI string `json:"informationUri"` - Rules []sarifReportingDesc `json:"rules,omitempty"` - SemanticVersion string `json:"semanticVersion"` -} - -// sarifReportingDesc describes a rule in the tool. -type sarifReportingDesc struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` - ShortDescription sarifMultiformat `json:"shortDescription"` - FullDescription sarifMultiformat `json:"fullDescription,omitempty"` - HelpURI string `json:"helpUri,omitempty"` - Help *sarifMultiformat `json:"help,omitempty"` - Properties *sarifRuleProps `json:"properties,omitempty"` -} - -// sarifRuleProps holds additional rule metadata. -type sarifRuleProps struct { - Tags []string `json:"tags,omitempty"` -} - -// sarifMultiformat is a text/markdown pair. -type sarifMultiformat struct { - Text string `json:"text"` -} - -// sarifResult is a single finding in SARIF format. -type sarifResult struct { - RuleID string `json:"ruleId"` - RuleIndex int `json:"ruleIndex"` - Level string `json:"level"` - Message sarifMultiformat `json:"message"` - Locations []sarifLocation `json:"locations,omitempty"` - Fixes []sarifFix `json:"fixes,omitempty"` -} - -// sarifLocation describes where a result was found. -type sarifLocation struct { - PhysicalLocation sarifPhysicalLoc `json:"physicalLocation"` -} - -// sarifPhysicalLoc has the artifact and region. -type sarifPhysicalLoc struct { - ArtifactLocation sarifArtifactLoc `json:"artifactLocation"` - Region *sarifRegion `json:"region,omitempty"` -} - -// sarifArtifactLoc identifies the file. -type sarifArtifactLoc struct { - URI string `json:"uri"` - URIBaseID string `json:"uriBaseId,omitempty"` -} -// sarifRegion identifies the line(s). -type sarifRegion struct { - StartLine int `json:"startLine"` - EndLine int `json:"endLine,omitempty"` -} + "github.com/GrayCodeAI/hawk/sarif" +) -// sarifFix describes a potential fix. -type sarifFix struct { - Description sarifMultiformat `json:"description"` -} +// SARIF 2.1.0 output support — emitted via the shared github.com/GrayCodeAI/hawk/sarif +// package so sight and inspect produce structurally identical output and the +// SARIF type tree only lives in one place. -// ToSARIF converts a slice of Finding values into a SARIF 2.1.0 JSON string. -// The output is compatible with GitHub Code Scanning, VS Code SARIF Viewer, -// and other SARIF-consuming tools. +// ToSARIF converts a slice of Finding values into a SARIF 2.1.0 JSON string +// compatible with GitHub Code Scanning, VS Code SARIF Viewer, and other +// SARIF-consuming tools. func ToSARIF(findings []Finding) string { - // Build rules index from findings - type ruleKey struct { - id string - } - ruleIndex := make(map[string]int) - var rules []sarifReportingDesc + b := sarif.New(sarif.Tool{ + Name: "sight", + Version: Version, + InformationURI: "https://github.com/GrayCodeAI/sight", + }) for _, f := range findings { ruleID := extractRuleID(f.Message) if ruleID == "" { ruleID = f.Concern } - if _, exists := ruleIndex[ruleID]; !exists { - ruleIndex[ruleID] = len(rules) - desc := sarifReportingDesc{ - ID: ruleID, - ShortDescription: sarifMultiformat{Text: extractRuleName(f.Message)}, - FullDescription: sarifMultiformat{Text: f.Message}, - } - if f.CWE != "" { - desc.Properties = &sarifRuleProps{ - Tags: []string{"security", f.CWE}, - } - desc.HelpURI = "https://cwe.mitre.org/data/definitions/" + strings.TrimPrefix(f.CWE, "CWE-") + ".html" - } - rules = append(rules, desc) - } - } - // Build results - results := make([]sarifResult, 0, len(findings)) - for _, f := range findings { - ruleID := extractRuleID(f.Message) - if ruleID == "" { - ruleID = f.Concern + // Register the rule (deduped by ID inside the builder). + rule := sarif.Rule{ + ID: ruleID, + ShortDescription: extractRuleName(f.Message), + FullDescription: f.Message, + Severity: severityToSARIF(f.Severity), } - idx := ruleIndex[ruleID] - - result := sarifResult{ - RuleID: ruleID, - RuleIndex: idx, - Level: severityToSARIFLevel(f.Severity), - Message: sarifMultiformat{Text: f.Message}, + if f.CWE != "" { + rule.Tags = []string{"security", f.CWE} + rule.HelpURI = "https://cwe.mitre.org/data/definitions/" + + strings.TrimPrefix(f.CWE, "CWE-") + ".html" } + b.AddRule(rule) + // Add the result. + result := sarif.Result{ + RuleID: ruleID, + Severity: severityToSARIF(f.Severity), + Message: f.Message, + } if f.File != "" { - loc := sarifLocation{ - PhysicalLocation: sarifPhysicalLoc{ - ArtifactLocation: sarifArtifactLoc{ - URI: f.File, - URIBaseID: "%SRCROOT%", - }, - }, - } + result.URI = f.File if f.Line > 0 { - loc.PhysicalLocation.Region = &sarifRegion{ + result.Region = &sarif.Region{ StartLine: f.Line, - EndLine: f.EndLine, - } - if f.EndLine == 0 { - loc.PhysicalLocation.Region.EndLine = f.Line + EndLine: f.EndLine, // 0 → builder defaults to StartLine } } - result.Locations = []sarifLocation{loc} } - if f.Fix != "" { - result.Fixes = []sarifFix{ - {Description: sarifMultiformat{Text: f.Fix}}, - } + result.Fix = f.Fix } - - results = append(results, result) + b.AddResult(result) } - log := sarifLog{ - Schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", - Version: "2.1.0", - Runs: []sarifRun{ - { - Tool: sarifTool{ - Driver: sarifDriver{ - Name: "sight", - Version: "1.0.0", - SemanticVersion: "1.0.0", - InformationURI: "https://github.com/GrayCodeAI/sight", - Rules: rules, - }, - }, - Results: results, - }, - }, - } - - data, err := json.MarshalIndent(log, "", " ") - if err != nil { - return "{}" - } - return string(data) + return b.String() } -// severityToSARIFLevel maps sight Severity to SARIF level strings. -func severityToSARIFLevel(s Severity) string { +// severityToSARIF maps sight Severity to sarif.Severity. +func severityToSARIF(s Severity) sarif.Severity { switch s { case SeverityCritical, SeverityHigh: - return "error" + return sarif.SeverityError case SeverityMedium: - return "warning" + return sarif.SeverityWarning case SeverityLow: - return "note" + return sarif.SeverityNote default: - return "none" + return sarif.SeverityNone } } diff --git a/version.go b/version.go new file mode 100644 index 0000000..b0c0f40 --- /dev/null +++ b/version.go @@ -0,0 +1,27 @@ +// Package sight version metadata. +// +// The Version variable is sourced at compile time from the VERSION file at +// the repo root, which is the single source of truth used by release tooling +// (release-please, goreleaser), CI, and the SARIF tool driver field. +package sight + +import ( + _ "embed" + "strings" + + "github.com/GrayCodeAI/sight/internal/output" +) + +//go:embed VERSION +var versionFile string + +// Version of the sight library. Do not edit this variable directly — bump +// the VERSION file at the repo root instead. +var Version = strings.TrimSpace(versionFile) + +func init() { + // Propagate canonical version into the internal/output package so the + // SARIF tool driver field reflects the real version without duplicating + // the constant across files. + output.SetToolVersion(Version) +}