nfrgate 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nfrgate-0.1.0/PKG-INFO +25 -0
- nfrgate-0.1.0/README.md +62 -0
- nfrgate-0.1.0/pyproject.toml +62 -0
- nfrgate-0.1.0/setup.cfg +4 -0
- nfrgate-0.1.0/src/nfr_gate/__init__.py +0 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/__init__.py +0 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/base.py +67 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/cli.py +83 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/constructs.py +113 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/diff_parser.py +73 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/languages/__init__.py +0 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/languages/go_analyzer.py +21 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/languages/java_analyzer.py +21 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/languages/python_analyzer.py +21 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/languages/registry.py +33 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/go/error_checks.scm +22 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/go/metric_label_calls.scm +9 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/go/outbound_calls.scm +13 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/go/retry_blocks.scm +14 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/go/route_definitions.scm +16 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/go/span_calls.scm +10 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/java/exception_handlers.scm +3 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/java/metric_label_calls.scm +10 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/java/outbound_calls.scm +14 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/java/retry_blocks.scm +9 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/java/route_definitions.scm +13 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/java/span_calls.scm +11 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/python/exception_handlers.scm +5 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/python/metric_label_calls.scm +11 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/python/outbound_calls.scm +12 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/python/retry_blocks.scm +14 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/python/route_definitions.scm +14 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/queries/python/span_calls.scm +11 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/__init__.py +0 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/ast_helpers.py +227 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/context_propagation_rule.py +129 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/correlation_id_rule.py +57 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/degradation_defined_rule.py +106 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/__init__.py +0 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/ast_helpers.py +294 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/context_propagation_rule.py +65 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/correlation_id_rule.py +65 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/degradation_defined_rule.py +115 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/latency_metric_rule.py +53 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/log_severity_rule.py +83 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/metric_cardinality_rule.py +52 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/no_swallowed_exception_rule.py +120 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/outcome_metric_rule.py +51 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/retry_backoff_rule.py +59 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/span_attributes_rule.py +55 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/span_error_status_rule.py +61 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/structured_log_rule.py +76 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/timeout_rule.py +51 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/trace_span_rule.py +53 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/__init__.py +0 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/ast_helpers.py +234 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/context_propagation_rule.py +73 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/correlation_id_rule.py +55 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/degradation_defined_rule.py +84 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/latency_metric_rule.py +57 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/log_severity_rule.py +78 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/metric_cardinality_rule.py +55 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/no_swallowed_exception_rule.py +64 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/outcome_metric_rule.py +51 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/retry_backoff_rule.py +60 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/span_attributes_rule.py +57 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/span_error_status_rule.py +52 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/structured_log_rule.py +63 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/timeout_rule.py +55 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/trace_span_rule.py +50 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/latency_metric_rule.py +61 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/log_severity_rule.py +89 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/metric_cardinality_rule.py +72 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/no_swallowed_exception_rule.py +99 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/outcome_metric_rule.py +55 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/registry.py +108 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/retry_backoff_rule.py +94 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/span_attributes_rule.py +72 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/span_error_status_rule.py +54 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/structured_log_rule.py +74 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/timeout_rule.py +55 -0
- nfrgate-0.1.0/src/nfr_gate/analysis/rules/trace_span_rule.py +58 -0
- nfrgate-0.1.0/src/nfr_gate/api/__init__.py +0 -0
- nfrgate-0.1.0/src/nfr_gate/api/app.py +55 -0
- nfrgate-0.1.0/src/nfr_gate/api/auth.py +28 -0
- nfrgate-0.1.0/src/nfr_gate/api/routes/__init__.py +0 -0
- nfrgate-0.1.0/src/nfr_gate/api/routes/overrides.py +77 -0
- nfrgate-0.1.0/src/nfr_gate/ci/__init__.py +0 -0
- nfrgate-0.1.0/src/nfr_gate/ci/comment.py +139 -0
- nfrgate-0.1.0/src/nfr_gate/ci/git_diff.py +36 -0
- nfrgate-0.1.0/src/nfr_gate/ci/github_api.py +58 -0
- nfrgate-0.1.0/src/nfr_gate/ci/gitlab_api.py +64 -0
- nfrgate-0.1.0/src/nfr_gate/ci/live_assessment.py +173 -0
- nfrgate-0.1.0/src/nfr_gate/ci/mr_gate.py +162 -0
- nfrgate-0.1.0/src/nfr_gate/ci/override.py +128 -0
- nfrgate-0.1.0/src/nfr_gate/ci/pipeline.py +51 -0
- nfrgate-0.1.0/src/nfr_gate/ci/pr_gate.py +148 -0
- nfrgate-0.1.0/src/nfr_gate/config.py +53 -0
- nfrgate-0.1.0/src/nfr_gate/domain/__init__.py +0 -0
- nfrgate-0.1.0/src/nfr_gate/domain/models/__init__.py +12 -0
- nfrgate-0.1.0/src/nfr_gate/domain/models/finding.py +98 -0
- nfrgate-0.1.0/src/nfr_gate/domain/models/routing.py +51 -0
- nfrgate-0.1.0/src/nfr_gate/domain/ports/__init__.py +0 -0
- nfrgate-0.1.0/src/nfr_gate/domain/ports/findings_repository.py +98 -0
- nfrgate-0.1.0/src/nfr_gate/infra/__init__.py +0 -0
- nfrgate-0.1.0/src/nfr_gate/infra/fake_findings_repository.py +141 -0
- nfrgate-0.1.0/src/nfr_gate/infra/postgres/__init__.py +0 -0
- nfrgate-0.1.0/src/nfr_gate/infra/postgres/connection.py +14 -0
- nfrgate-0.1.0/src/nfr_gate/infra/postgres/findings_repository.py +168 -0
- nfrgate-0.1.0/src/nfr_gate/llm/__init__.py +0 -0
- nfrgate-0.1.0/src/nfr_gate/llm/assessor.py +24 -0
- nfrgate-0.1.0/src/nfr_gate/llm/client.py +94 -0
- nfrgate-0.1.0/src/nfr_gate/llm/prompt.py +86 -0
- nfrgate-0.1.0/src/nfr_gate/llm/response.py +54 -0
- nfrgate-0.1.0/src/nfr_gate/observability.py +159 -0
- nfrgate-0.1.0/src/nfr_gate/routing/__init__.py +0 -0
- nfrgate-0.1.0/src/nfr_gate/routing/policy.py +77 -0
- nfrgate-0.1.0/src/nfr_gate/routing/service_tiers.py +65 -0
- nfrgate-0.1.0/src/nfrgate.egg-info/PKG-INFO +25 -0
- nfrgate-0.1.0/src/nfrgate.egg-info/SOURCES.txt +130 -0
- nfrgate-0.1.0/src/nfrgate.egg-info/dependency_links.txt +1 -0
- nfrgate-0.1.0/src/nfrgate.egg-info/entry_points.txt +5 -0
- nfrgate-0.1.0/src/nfrgate.egg-info/requires.txt +23 -0
- nfrgate-0.1.0/src/nfrgate.egg-info/top_level.txt +2 -0
- nfrgate-0.1.0/src/rubric_store/__init__.py +0 -0
- nfrgate-0.1.0/src/rubric_store/definitions/logs.yaml +67 -0
- nfrgate-0.1.0/src/rubric_store/definitions/metrics.yaml +66 -0
- nfrgate-0.1.0/src/rubric_store/definitions/reliability.yaml +60 -0
- nfrgate-0.1.0/src/rubric_store/definitions/traces.yaml +64 -0
- nfrgate-0.1.0/src/rubric_store/loader.py +78 -0
- nfrgate-0.1.0/src/rubric_store/schema/rubric.schema.yaml +125 -0
- nfrgate-0.1.0/src/rubric_store/sync.py +89 -0
nfrgate-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nfrgate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-based Observability & NFR Gating Platform
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: tree-sitter>=0.23
|
|
7
|
+
Requires-Dist: tree-sitter-python>=0.23
|
|
8
|
+
Requires-Dist: tree-sitter-java>=0.23
|
|
9
|
+
Requires-Dist: tree-sitter-go>=0.23
|
|
10
|
+
Requires-Dist: pydantic>=2.0
|
|
11
|
+
Requires-Dist: pyyaml>=6.0
|
|
12
|
+
Requires-Dist: psycopg[binary]>=3.1
|
|
13
|
+
Requires-Dist: alembic>=1.13
|
|
14
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
15
|
+
Requires-Dist: opentelemetry-api>=1.24
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
18
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
19
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
20
|
+
Provides-Extra: api
|
|
21
|
+
Requires-Dist: fastapi>=0.110; extra == "api"
|
|
22
|
+
Requires-Dist: uvicorn>=0.29; extra == "api"
|
|
23
|
+
Provides-Extra: otel
|
|
24
|
+
Requires-Dist: opentelemetry-sdk>=1.24; extra == "otel"
|
|
25
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.24; extra == "otel"
|
nfrgate-0.1.0/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# NFR Gate
|
|
2
|
+
|
|
3
|
+
AI-based Observability & NFR (Non-Functional Requirement) Gating Platform — static analysis (Python/Java/Go) plus LLM semantic assessment, with confidence-based routing to gate CI/CD merges. See `docs/nfr_rubric_v1.md` for the rubric itself and `docs/` more broadly for the design/measurement docs (false-positive/false-negative studies, confidence retuning, implementation spec).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install nfrgate # static analysis + LLM assessment
|
|
9
|
+
pip install "nfrgate[api]" # + the overrides HTTP API (FastAPI/uvicorn)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or via Docker (builds and runs the overrides API by default — see below):
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
docker build -t nfrgate .
|
|
16
|
+
docker run --rm -p 8000:8000 -e NFR_GATE_API_KEY=<a-real-secret> nfrgate
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Configuration
|
|
20
|
+
|
|
21
|
+
Every piece of deployment-specific config is an environment variable — no source edits needed to point at different infrastructure. See `src/nfr_gate/config.py` for the single source of truth; summary:
|
|
22
|
+
|
|
23
|
+
| Env var | Default | Purpose |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `NFR_GATE_DATABASE_URL` | `postgresql://nfr_gate:nfr_gate_dev@localhost:5432/nfr_gate` | Postgres connection for the findings store |
|
|
26
|
+
| `NFR_GATE_SERVICE_TIERS_PATH` | `config/service_tiers.yaml` (repo checkout only) | Service-criticality tier registry; missing file → every service defaults to the most conservative tier (`tier0`), never a crash |
|
|
27
|
+
| `NFR_GATE_LLM_BACKEND` | `ollama` | `ollama` (local model, no API key) or `anthropic` (hosted, needs `ANTHROPIC_API_KEY`) |
|
|
28
|
+
| `NFR_GATE_LLM_MODEL` | backend's own default | Override the model name for either backend |
|
|
29
|
+
| `NFR_GATE_OLLAMA_BASE_URL` | `http://localhost:11434` | Where to reach Ollama |
|
|
30
|
+
| `NFR_GATE_API_KEY` | *(none — required to start the API)* | Shared bearer-token secret the overrides API checks and the CLI sends |
|
|
31
|
+
| `NFR_GATE_API_URL` | `http://localhost:8000` | Where the `nfrgate-override` CLI reaches the API |
|
|
32
|
+
| `NFR_GATE_API_PORT` | `8000` | Port `nfrgate-api` binds to |
|
|
33
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | *(unset — tracing/metrics are no-ops)* | Where nfrgate exports its own traces/metrics; needs `pip install "nfrgate[otel]"` |
|
|
34
|
+
| `NFR_GATE_LOG_LEVEL` | `INFO` | Level for nfrgate's own structured JSON logs (to stderr) |
|
|
35
|
+
|
|
36
|
+
## Observability (nfrgate observes itself)
|
|
37
|
+
|
|
38
|
+
nfrgate emits the same three signals it grades other services on: structured JSON logs (stderr, always on, each line carrying the current trace id as its correlation id), OpenTelemetry spans around each pipeline run and each LLM call, and metrics for exactly the things the platform depends on being healthy — `nfrgate.pipeline.duration_ms`, `nfrgate.llm.calls` (by success/error), `nfrgate.db.write_failures`. Tracing/metrics use OpenTelemetry's standard no-op providers until `OTEL_EXPORTER_OTLP_ENDPOINT` is set (and the `otel` extra installed), so nothing needs to be stood up just to run the CI gate.
|
|
39
|
+
|
|
40
|
+
## Running it
|
|
41
|
+
|
|
42
|
+
- **CI gate**: `python -m nfr_gate.ci.pr_gate` (GitHub Actions) / `python -m nfr_gate.ci.mr_gate` (GitLab CI) — reads PR/MR context from the CI environment, analyzes the diff, posts a comment, and fails the job on high-confidence violations.
|
|
43
|
+
- **Overrides API**: `nfrgate-api` (or `uvicorn nfr_gate.api.app:app`) — HTTP surface for the human-override audit trail; requires `NFR_GATE_API_KEY`. Serves plain HTTP — put a TLS-terminating reverse proxy/load balancer in front of it for any non-localhost deployment (the bearer token is interceptable in transit otherwise). See `docs/security_review_v1.md` for this and other accepted/mitigated risks on this API and the LLM prompt surface.
|
|
44
|
+
- `GET /healthz` — unauthenticated liveness probe (`{"status": "ok"}`), independent of database reachability; point a k8s/load-balancer health check here.
|
|
45
|
+
- **Overrides CLI**: `nfrgate-override list --source-ref <ref>` / `nfrgate-override apply ...` — a thin authenticated client of the API above.
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install -e ".[dev,api]"
|
|
51
|
+
cd db && docker compose up -d && cd ..
|
|
52
|
+
alembic upgrade head
|
|
53
|
+
pytest
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Schema changes are Alembic migrations under `db/alembic/versions/`, applied with `alembic upgrade head` — never hand-run SQL against a live database.
|
|
57
|
+
|
|
58
|
+
## CI/CD
|
|
59
|
+
|
|
60
|
+
- `.github/workflows/ci.yml` — every push/PR: `ruff check .`, `alembic upgrade head` against a service-container Postgres, then the full unit + integration suite.
|
|
61
|
+
- `.github/workflows/release.yml` — on a `vX.Y.Z` tag: builds the sdist/wheel (uploaded as a build artifact regardless), publishes to PyPI if `PYPI_API_TOKEN` is set, and builds/pushes the Docker image to `ghcr.io/<this repo>` (no extra secret needed).
|
|
62
|
+
- `.github/workflows/nfrgate-ci.yml` — dogfooding: runs nfrgate's own static analyzer against PRs opened on this repo.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nfrgate"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "AI-based Observability & NFR Gating Platform"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"tree-sitter>=0.23",
|
|
8
|
+
"tree-sitter-python>=0.23",
|
|
9
|
+
"tree-sitter-java>=0.23",
|
|
10
|
+
"tree-sitter-go>=0.23",
|
|
11
|
+
"pydantic>=2.0",
|
|
12
|
+
"pyyaml>=6.0",
|
|
13
|
+
"psycopg[binary]>=3.1",
|
|
14
|
+
"alembic>=1.13",
|
|
15
|
+
"sqlalchemy>=2.0",
|
|
16
|
+
"opentelemetry-api>=1.24",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=8.0",
|
|
22
|
+
"httpx>=0.27",
|
|
23
|
+
"ruff>=0.6",
|
|
24
|
+
]
|
|
25
|
+
api = [
|
|
26
|
+
"fastapi>=0.110",
|
|
27
|
+
"uvicorn>=0.29",
|
|
28
|
+
]
|
|
29
|
+
otel = [
|
|
30
|
+
"opentelemetry-sdk>=1.24",
|
|
31
|
+
"opentelemetry-exporter-otlp-proto-http>=1.24",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
nfrgate-pr-gate = "nfr_gate.ci.pr_gate:main"
|
|
36
|
+
nfrgate-mr-gate = "nfr_gate.ci.mr_gate:main"
|
|
37
|
+
nfrgate-override = "nfr_gate.ci.override:main"
|
|
38
|
+
nfrgate-api = "nfr_gate.api.app:main"
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["setuptools>=68"]
|
|
42
|
+
build-backend = "setuptools.build_meta"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
where = ["src"]
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.package-data]
|
|
48
|
+
"nfr_gate.analysis.queries" = ["**/*.scm"]
|
|
49
|
+
"rubric_store" = ["definitions/*.yaml", "schema/*.yaml"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
line-length = 120
|
|
53
|
+
target-version = "py312"
|
|
54
|
+
extend-exclude = ["tests/fixtures"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
select = ["E", "F", "I"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint.per-file-ignores]
|
|
60
|
+
# Test literals (JSON payloads, error strings) read better whole than
|
|
61
|
+
# wrapped — production code under src/ stays strictly within line-length.
|
|
62
|
+
"tests/**" = ["E501"]
|
nfrgate-0.1.0/setup.cfg
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Language-agnostic core of the AST parsing pipeline. `LanguageAnalyzer` is
|
|
2
|
+
the Strategy interface (technical_implementation_spec.md §C.1) — the pipeline
|
|
3
|
+
that walks a `DiffContext` for constructs of interest never imports a
|
|
4
|
+
concrete language analyzer, only this interface, so adding Go or Java later
|
|
5
|
+
means adding a new `languages/*.py` file, not touching this one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from tree_sitter import Language, Tree
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
# Deferred to avoid a circular import: constructs.py imports DiffContext
|
|
18
|
+
# from this module. Safe at type-check time only because of the
|
|
19
|
+
# `from __future__ import annotations` above (annotations are strings,
|
|
20
|
+
# never evaluated at runtime).
|
|
21
|
+
from nfr_gate.analysis.constructs import Construct
|
|
22
|
+
from nfr_gate.domain.models.finding import Finding, FindingCategory
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class DiffContext:
|
|
27
|
+
file_path: str
|
|
28
|
+
language: str
|
|
29
|
+
tree: Tree
|
|
30
|
+
source_bytes: bytes
|
|
31
|
+
changed_line_ranges: list[tuple[int, int]] # from diff_parser.FileDiff.added_line_ranges
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LanguageAnalyzer(ABC):
|
|
35
|
+
language: str
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def ts_language(self) -> Language:
|
|
40
|
+
"""The compiled tree-sitter grammar, needed to build Query objects
|
|
41
|
+
against this language's node types."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def parse(self, source: bytes) -> Tree: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RuleEvaluator(ABC):
|
|
48
|
+
"""Strategy interface: one implementation per static-analyzable (or
|
|
49
|
+
static-component-of-'both') rule code. The pipeline calls every
|
|
50
|
+
applicable evaluator with the *same* pre-computed construct list from
|
|
51
|
+
constructs.find_constructs() — evaluators do not run their own queries,
|
|
52
|
+
so adding a 5th rule never means re-parsing or re-querying the diff."""
|
|
53
|
+
|
|
54
|
+
rule_code: str
|
|
55
|
+
category: "FindingCategory"
|
|
56
|
+
languages: frozenset[str]
|
|
57
|
+
|
|
58
|
+
def applies_to(self, language: str) -> bool:
|
|
59
|
+
return language in self.languages
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def evaluate(self, ctx: DiffContext, constructs: list["Construct"]) -> list["Finding"]:
|
|
63
|
+
"""Implementations filter `constructs` to the kind(s) and
|
|
64
|
+
`in_diff=True` entries they care about, and return one Finding per
|
|
65
|
+
construct they have an opinion on. Constructs the rule doesn't
|
|
66
|
+
apply to are simply not represented in the output — evaluate()
|
|
67
|
+
never raises or returns a placeholder for "not applicable"."""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Sprint 2 done-when demo:
|
|
2
|
+
|
|
3
|
+
python -m nfr_gate.analysis.cli <diff_file> <source_file>
|
|
4
|
+
|
|
5
|
+
Given a PR diff and the post-change source file it targets (as the CI job
|
|
6
|
+
would have from a real checkout — see diff_parser.py's docstring for why the
|
|
7
|
+
diff itself isn't used to reconstruct content), parses the file to an AST,
|
|
8
|
+
prints the syntax tree, lists every construct of interest the query files
|
|
9
|
+
find (flagging which fall inside lines the diff actually added), and runs
|
|
10
|
+
every registered rule evaluator against them, printing the resulting
|
|
11
|
+
normalized Findings — this is the full parse -> construct -> Finding
|
|
12
|
+
pipeline end to end, not just the parsing half.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from nfr_gate.analysis.base import DiffContext
|
|
21
|
+
from nfr_gate.analysis.constructs import find_constructs
|
|
22
|
+
from nfr_gate.analysis.diff_parser import parse_unified_diff
|
|
23
|
+
from nfr_gate.analysis.languages.python_analyzer import PythonAnalyzer
|
|
24
|
+
from nfr_gate.analysis.rules.registry import rules_for_language
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def analyze(diff_path: Path, source_path: Path) -> None:
|
|
28
|
+
file_diffs = parse_unified_diff(diff_path.read_text())
|
|
29
|
+
if not file_diffs:
|
|
30
|
+
raise SystemExit(f"No file diffs found in {diff_path}")
|
|
31
|
+
file_diff = file_diffs[0] # sample diff touches exactly one file
|
|
32
|
+
|
|
33
|
+
analyzer = PythonAnalyzer()
|
|
34
|
+
source_bytes = source_path.read_bytes()
|
|
35
|
+
tree = analyzer.parse(source_bytes)
|
|
36
|
+
|
|
37
|
+
ctx = DiffContext(
|
|
38
|
+
file_path=file_diff.file_path,
|
|
39
|
+
language=analyzer.language,
|
|
40
|
+
tree=tree,
|
|
41
|
+
source_bytes=source_bytes,
|
|
42
|
+
changed_line_ranges=file_diff.added_line_ranges,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
print(f"=== Parsed {ctx.file_path} ({len(source_bytes)} bytes, {tree.root_node.end_point[0] + 1} lines) ===")
|
|
46
|
+
print(f"Changed line ranges from diff: {ctx.changed_line_ranges}")
|
|
47
|
+
print()
|
|
48
|
+
print("=== Syntax tree (S-expression) ===")
|
|
49
|
+
print(str(tree.root_node))
|
|
50
|
+
print()
|
|
51
|
+
|
|
52
|
+
constructs = find_constructs(ctx, analyzer.ts_language)
|
|
53
|
+
in_diff_count = sum(c.in_diff for c in constructs)
|
|
54
|
+
print(f"=== Constructs found: {len(constructs)} ({in_diff_count} within changed lines) ===")
|
|
55
|
+
for c in sorted(constructs, key=lambda c: c.start_line):
|
|
56
|
+
marker = "DIFF" if c.in_diff else " "
|
|
57
|
+
first_line = c.snippet.splitlines()[0]
|
|
58
|
+
print(f"[{marker}] {c.kind:<16} L{c.start_line}-{c.end_line} {first_line}")
|
|
59
|
+
print()
|
|
60
|
+
|
|
61
|
+
findings = [
|
|
62
|
+
finding
|
|
63
|
+
for rule in rules_for_language(analyzer.language)
|
|
64
|
+
for finding in rule.evaluate(ctx, constructs)
|
|
65
|
+
]
|
|
66
|
+
print(f"=== Findings: {len(findings)} ===")
|
|
67
|
+
for f in sorted(findings, key=lambda f: (f.line_number or 0, f.rule_code)):
|
|
68
|
+
print(
|
|
69
|
+
f"[{f.result:<7}] {f.rule_code} L{f.line_number} "
|
|
70
|
+
f"confidence={f.confidence:.2f} {f.evidence.splitlines()[0]}"
|
|
71
|
+
)
|
|
72
|
+
if f.reasoning:
|
|
73
|
+
print(f" {f.reasoning}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main() -> None:
|
|
77
|
+
if len(sys.argv) != 3:
|
|
78
|
+
raise SystemExit("usage: python -m nfr_gate.analysis.cli <diff_file> <source_file>")
|
|
79
|
+
analyze(Path(sys.argv[1]), Path(sys.argv[2]))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
main()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Runs the tree-sitter query files in queries/{language}/ against a parsed
|
|
2
|
+
AST and yields the constructs of interest they find. This is deliberately
|
|
3
|
+
construct identification only — no confidence score, no pass/fail, no
|
|
4
|
+
normalized Finding — that's the next task (normalized finding interface +
|
|
5
|
+
rule evaluators). This module just answers "where are the outbound calls,
|
|
6
|
+
catch blocks, and route definitions," restricted to lines the diff touched.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from tree_sitter import Language, Node, Query, QueryCursor
|
|
16
|
+
|
|
17
|
+
from nfr_gate.analysis.base import DiffContext
|
|
18
|
+
from nfr_gate.analysis.diff_parser import line_in_ranges
|
|
19
|
+
|
|
20
|
+
QUERIES_DIR = Path(__file__).parent / "queries"
|
|
21
|
+
|
|
22
|
+
# Per language: (query filename stem, capture name for the whole construct,
|
|
23
|
+
# construct kind). `kind` is the language-agnostic label rule evaluators
|
|
24
|
+
# filter on — languages whose grammar has no direct equivalent of a
|
|
25
|
+
# Python-style except block (Go) still produce a `catch_block`-kind
|
|
26
|
+
# construct from whatever their own error-handling idiom's query finds
|
|
27
|
+
# (Go: an `if err != nil { ... }` block), so rule logic that filters on
|
|
28
|
+
# `kind` doesn't need to know which language it's looking at.
|
|
29
|
+
CONSTRUCT_QUERIES: dict[str, list[tuple[str, str, str]]] = {
|
|
30
|
+
"python": [
|
|
31
|
+
("outbound_calls", "outbound_call", "external_call"),
|
|
32
|
+
("exception_handlers", "catch_block", "catch_block"),
|
|
33
|
+
("route_definitions", "route_definition", "route_definition"),
|
|
34
|
+
("retry_blocks", "sleep_call", "sleep_call"),
|
|
35
|
+
("span_calls", "span_call", "span_call"),
|
|
36
|
+
("metric_label_calls", "metric_label_call", "metric_label_call"),
|
|
37
|
+
],
|
|
38
|
+
"java": [
|
|
39
|
+
("outbound_calls", "outbound_call", "external_call"),
|
|
40
|
+
("exception_handlers", "catch_block", "catch_block"),
|
|
41
|
+
("retry_blocks", "sleep_call", "sleep_call"),
|
|
42
|
+
("route_definitions", "route_definition", "route_definition"),
|
|
43
|
+
("span_calls", "span_call", "span_call"),
|
|
44
|
+
("metric_label_calls", "metric_label_call", "metric_label_call"),
|
|
45
|
+
],
|
|
46
|
+
"go": [
|
|
47
|
+
("outbound_calls", "outbound_call", "external_call"),
|
|
48
|
+
("error_checks", "error_check_block", "catch_block"),
|
|
49
|
+
("retry_blocks", "sleep_call", "sleep_call"),
|
|
50
|
+
("route_definitions", "route_definition", "route_definition"),
|
|
51
|
+
("span_calls", "span_call", "span_call"),
|
|
52
|
+
("metric_label_calls", "metric_label_call", "metric_label_call"),
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Construct:
|
|
59
|
+
kind: str # 'external_call' | 'catch_block' | 'route_definition' | 'sleep_call' | 'span_call' | 'metric_label_call'
|
|
60
|
+
file_path: str
|
|
61
|
+
start_line: int # 1-indexed
|
|
62
|
+
end_line: int
|
|
63
|
+
snippet: str
|
|
64
|
+
in_diff: bool # whether this construct falls within a changed line range
|
|
65
|
+
node: Node = field(repr=False, compare=False)
|
|
66
|
+
# Rule evaluators need the actual node (to inspect arguments, walk
|
|
67
|
+
# parents for an enclosing span/loop, etc.) — constructs.py only finds
|
|
68
|
+
# *where* things are; rule-specific inspection stays in analysis/rules/.
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@lru_cache(maxsize=None)
|
|
72
|
+
def _load_query(ts_language: Language, language: str, query_name: str) -> Query:
|
|
73
|
+
# Query compilation is real, non-trivial work (parses the .scm pattern
|
|
74
|
+
# itself). The query text is static per (language, query_name) — a CI
|
|
75
|
+
# run touching N files must not recompile the same 3 queries N times.
|
|
76
|
+
# Cached for the life of the process, keyed by object identity of
|
|
77
|
+
# ts_language, which is stable for a given LanguageAnalyzer instance.
|
|
78
|
+
path = QUERIES_DIR / language / f"{query_name}.scm"
|
|
79
|
+
return Query(ts_language, path.read_text())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def find_constructs(ctx: DiffContext, ts_language: Language) -> list[Construct]:
|
|
83
|
+
"""Runs every registered query for ctx.language and returns every match,
|
|
84
|
+
tagged with whether it falls inside a line range the diff actually added.
|
|
85
|
+
Callers that only care about new code (e.g. static rule evaluators, which
|
|
86
|
+
should not re-flag pre-existing gaps) filter on `in_diff`."""
|
|
87
|
+
results: list[Construct] = []
|
|
88
|
+
|
|
89
|
+
for query_name, capture_name, kind in CONSTRUCT_QUERIES.get(ctx.language, []):
|
|
90
|
+
query = _load_query(ts_language, ctx.language, query_name)
|
|
91
|
+
cursor = QueryCursor(query)
|
|
92
|
+
matches = cursor.matches(ctx.tree.root_node)
|
|
93
|
+
|
|
94
|
+
for _pattern_index, captures in matches:
|
|
95
|
+
for node in captures.get(capture_name, []):
|
|
96
|
+
start_line = node.start_point[0] + 1
|
|
97
|
+
end_line = node.end_point[0] + 1
|
|
98
|
+
results.append(
|
|
99
|
+
Construct(
|
|
100
|
+
kind=kind,
|
|
101
|
+
file_path=ctx.file_path,
|
|
102
|
+
start_line=start_line,
|
|
103
|
+
end_line=end_line,
|
|
104
|
+
snippet=node.text.decode("utf-8", errors="replace"),
|
|
105
|
+
in_diff=any(
|
|
106
|
+
line_in_ranges(line, ctx.changed_line_ranges)
|
|
107
|
+
for line in range(start_line, end_line + 1)
|
|
108
|
+
),
|
|
109
|
+
node=node,
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return results
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Parses a unified diff (`git diff` / `diff -u` format, same shape a PR diff
|
|
2
|
+
arrives in from GitHub/GitLab) into, per file, the line ranges that were
|
|
3
|
+
actually added — nothing more. It does not reconstruct file content: the
|
|
4
|
+
pipeline reads the real post-change source from the checked-out repo and
|
|
5
|
+
uses this module only to know which lines in that source are new, so rule
|
|
6
|
+
evaluators can skip untouched code instead of re-flagging pre-existing gaps
|
|
7
|
+
on every PR that happens to touch the same file.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
_HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class FileDiff:
|
|
20
|
+
file_path: str
|
|
21
|
+
added_line_ranges: list[tuple[int, int]] # 1-indexed, inclusive, in the NEW file
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_unified_diff(diff_text: str) -> list[FileDiff]:
|
|
25
|
+
files: list[FileDiff] = []
|
|
26
|
+
current_path: str | None = None
|
|
27
|
+
current_ranges: list[list[int]] = []
|
|
28
|
+
new_line_no: int | None = None
|
|
29
|
+
|
|
30
|
+
def flush() -> None:
|
|
31
|
+
nonlocal current_path, current_ranges
|
|
32
|
+
if current_path is not None:
|
|
33
|
+
files.append(FileDiff(current_path, [tuple(r) for r in current_ranges]))
|
|
34
|
+
current_path, current_ranges = None, []
|
|
35
|
+
|
|
36
|
+
for line in diff_text.splitlines():
|
|
37
|
+
if line.startswith("+++ "):
|
|
38
|
+
flush()
|
|
39
|
+
path = line[4:].strip()
|
|
40
|
+
current_path = path[2:] if path.startswith("b/") else path
|
|
41
|
+
new_line_no = None
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
if line.startswith("--- ") or line.startswith("diff ") or line.startswith("index "):
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
hunk_match = _HUNK_HEADER_RE.match(line)
|
|
48
|
+
if hunk_match:
|
|
49
|
+
new_line_no = int(hunk_match.group(3))
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
if current_path is None or new_line_no is None:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
if line.startswith("\\"): # ""
|
|
56
|
+
continue
|
|
57
|
+
if line.startswith("+"):
|
|
58
|
+
if current_ranges and current_ranges[-1][1] == new_line_no - 1:
|
|
59
|
+
current_ranges[-1][1] = new_line_no
|
|
60
|
+
else:
|
|
61
|
+
current_ranges.append([new_line_no, new_line_no])
|
|
62
|
+
new_line_no += 1
|
|
63
|
+
elif line.startswith("-"):
|
|
64
|
+
pass # removed line: doesn't exist in the new file, don't advance the counter
|
|
65
|
+
else:
|
|
66
|
+
new_line_no += 1 # context line
|
|
67
|
+
|
|
68
|
+
flush()
|
|
69
|
+
return files
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def line_in_ranges(line: int, ranges: list[tuple[int, int]]) -> bool:
|
|
73
|
+
return any(start <= line <= end for start, end in ranges)
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tree_sitter_go as tsgo
|
|
4
|
+
from tree_sitter import Language, Parser, Tree
|
|
5
|
+
|
|
6
|
+
from nfr_gate.analysis.base import LanguageAnalyzer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GoAnalyzer(LanguageAnalyzer):
|
|
10
|
+
language = "go"
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._ts_language = Language(tsgo.language())
|
|
14
|
+
self._parser = Parser(self._ts_language)
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def ts_language(self) -> Language:
|
|
18
|
+
return self._ts_language
|
|
19
|
+
|
|
20
|
+
def parse(self, source: bytes) -> Tree:
|
|
21
|
+
return self._parser.parse(source)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tree_sitter_java as tsjava
|
|
4
|
+
from tree_sitter import Language, Parser, Tree
|
|
5
|
+
|
|
6
|
+
from nfr_gate.analysis.base import LanguageAnalyzer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JavaAnalyzer(LanguageAnalyzer):
|
|
10
|
+
language = "java"
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._ts_language = Language(tsjava.language())
|
|
14
|
+
self._parser = Parser(self._ts_language)
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def ts_language(self) -> Language:
|
|
18
|
+
return self._ts_language
|
|
19
|
+
|
|
20
|
+
def parse(self, source: bytes) -> Tree:
|
|
21
|
+
return self._parser.parse(source)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tree_sitter_python as tspython
|
|
4
|
+
from tree_sitter import Language, Parser, Tree
|
|
5
|
+
|
|
6
|
+
from nfr_gate.analysis.base import LanguageAnalyzer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PythonAnalyzer(LanguageAnalyzer):
|
|
10
|
+
language = "python"
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._ts_language = Language(tspython.language())
|
|
14
|
+
self._parser = Parser(self._ts_language)
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def ts_language(self) -> Language:
|
|
18
|
+
return self._ts_language
|
|
19
|
+
|
|
20
|
+
def parse(self, source: bytes) -> Tree:
|
|
21
|
+
return self._parser.parse(source)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Maps a file extension to the LanguageAnalyzer that handles it — the one
|
|
2
|
+
place that needs to know which languages exist. constructs.py, the rule
|
|
3
|
+
evaluators, and the CI pipeline are all parameterized by `language: str`
|
|
4
|
+
and don't care how a file got mapped to one, so adding a fourth language
|
|
5
|
+
is additive here, not a change to any of them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from nfr_gate.analysis.base import LanguageAnalyzer
|
|
11
|
+
from nfr_gate.analysis.languages.go_analyzer import GoAnalyzer
|
|
12
|
+
from nfr_gate.analysis.languages.java_analyzer import JavaAnalyzer
|
|
13
|
+
from nfr_gate.analysis.languages.python_analyzer import PythonAnalyzer
|
|
14
|
+
|
|
15
|
+
# Instantiated once at import time — building a tree-sitter Parser per file
|
|
16
|
+
# would be the same wasted-repeated-work pattern the query cache in
|
|
17
|
+
# constructs.py already exists to avoid.
|
|
18
|
+
_ANALYZERS: dict[str, LanguageAnalyzer] = {
|
|
19
|
+
".py": PythonAnalyzer(),
|
|
20
|
+
".java": JavaAnalyzer(),
|
|
21
|
+
".go": GoAnalyzer(),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def analyzer_for_path(file_path: str) -> LanguageAnalyzer | None:
|
|
26
|
+
for ext, analyzer in _ANALYZERS.items():
|
|
27
|
+
if file_path.endswith(ext):
|
|
28
|
+
return analyzer
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def supported_extensions() -> list[str]:
|
|
33
|
+
return list(_ANALYZERS.keys())
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
; Go has no exception/catch construct — the idiomatic equivalent of "did
|
|
2
|
+
; this error path get handled" is `if err != nil { ... }`. This query
|
|
3
|
+
; matches that shape specifically (excludes `== nil`, i.e. the success
|
|
4
|
+
; branch) and maps to the SAME construct kind ("catch_block") that
|
|
5
|
+
; except_clause/catch_clause map to in Python/Java, so rule L2's logic
|
|
6
|
+
; doesn't need to know which language it's looking at — only that a
|
|
7
|
+
; "catch_block"-kind construct's body might or might not contain a
|
|
8
|
+
; structured log call.
|
|
9
|
+
;
|
|
10
|
+
; #match? on "err" is a substring match, matching Go's near-universal
|
|
11
|
+
; convention of naming error variables err/writeErr/parseErr/etc. — not a
|
|
12
|
+
; guarantee every err-shaped comparison is really an error check, but a
|
|
13
|
+
; defensible heuristic for v1.
|
|
14
|
+
|
|
15
|
+
(if_statement
|
|
16
|
+
condition: (binary_expression
|
|
17
|
+
left: (identifier) @_err_ident
|
|
18
|
+
"!="
|
|
19
|
+
right: (nil))
|
|
20
|
+
consequence: (block) @_consequence
|
|
21
|
+
(#match? @_err_ident "err")
|
|
22
|
+
) @error_check_block
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
; Matches `.With(...)` calls — the standard prometheus client_golang idiom
|
|
2
|
+
; for attaching a `prometheus.Labels{...}` composite literal to a metric
|
|
3
|
+
; before incrementing/observing it. M3 inspects the argument text for
|
|
4
|
+
; unbounded-looking keys, not the query's job.
|
|
5
|
+
|
|
6
|
+
(call_expression
|
|
7
|
+
function: (selector_expression field: (field_identifier) @_method)
|
|
8
|
+
(#eq? @_method "With")
|
|
9
|
+
) @metric_label_call
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
; Matches "obj.Method(...)"-shaped calls where the receiver is a plain
|
|
2
|
+
; identifier: client.Post(...), db.Query(...). Deliberately requires the
|
|
3
|
+
; operand to be a bare identifier (not a chained selector like
|
|
4
|
+
; resp.Body.Close()), which cuts some of the false-positive rate the
|
|
5
|
+
; Python/Java versions of this heuristic carry, as a side effect rather
|
|
6
|
+
; than a deliberate precision fix — still not a whitelist of real
|
|
7
|
+
; HTTP/DB clients. Consumed by rules T1 and R1.
|
|
8
|
+
|
|
9
|
+
(call_expression
|
|
10
|
+
function: (selector_expression
|
|
11
|
+
operand: (identifier) @callee_object
|
|
12
|
+
field: (field_identifier) @callee_method)
|
|
13
|
+
) @outbound_call
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
; Matches time.Sleep(...) — the standard Go retry-delay idiom. Consumed by
|
|
2
|
+
; rule R2 (retry_without_backoff). Classifying the delay expression's shape
|
|
3
|
+
; (fixed vs. backoff-shaped) happens in Python against the captured node —
|
|
4
|
+
; see analysis/rules/go/retry_backoff_rule.py, which has to account for
|
|
5
|
+
; Go's `N * time.Second` idiom meaning a FIXED delay, not backoff, unlike a
|
|
6
|
+
; bare arithmetic expression in Python.
|
|
7
|
+
|
|
8
|
+
(call_expression
|
|
9
|
+
function: (selector_expression
|
|
10
|
+
operand: (identifier) @_sleep_object
|
|
11
|
+
field: (field_identifier) @_sleep_method)
|
|
12
|
+
(#eq? @_sleep_object "time")
|
|
13
|
+
(#eq? @_sleep_method "Sleep")
|
|
14
|
+
) @sleep_call
|