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.
Files changed (132) hide show
  1. nfrgate-0.1.0/PKG-INFO +25 -0
  2. nfrgate-0.1.0/README.md +62 -0
  3. nfrgate-0.1.0/pyproject.toml +62 -0
  4. nfrgate-0.1.0/setup.cfg +4 -0
  5. nfrgate-0.1.0/src/nfr_gate/__init__.py +0 -0
  6. nfrgate-0.1.0/src/nfr_gate/analysis/__init__.py +0 -0
  7. nfrgate-0.1.0/src/nfr_gate/analysis/base.py +67 -0
  8. nfrgate-0.1.0/src/nfr_gate/analysis/cli.py +83 -0
  9. nfrgate-0.1.0/src/nfr_gate/analysis/constructs.py +113 -0
  10. nfrgate-0.1.0/src/nfr_gate/analysis/diff_parser.py +73 -0
  11. nfrgate-0.1.0/src/nfr_gate/analysis/languages/__init__.py +0 -0
  12. nfrgate-0.1.0/src/nfr_gate/analysis/languages/go_analyzer.py +21 -0
  13. nfrgate-0.1.0/src/nfr_gate/analysis/languages/java_analyzer.py +21 -0
  14. nfrgate-0.1.0/src/nfr_gate/analysis/languages/python_analyzer.py +21 -0
  15. nfrgate-0.1.0/src/nfr_gate/analysis/languages/registry.py +33 -0
  16. nfrgate-0.1.0/src/nfr_gate/analysis/queries/go/error_checks.scm +22 -0
  17. nfrgate-0.1.0/src/nfr_gate/analysis/queries/go/metric_label_calls.scm +9 -0
  18. nfrgate-0.1.0/src/nfr_gate/analysis/queries/go/outbound_calls.scm +13 -0
  19. nfrgate-0.1.0/src/nfr_gate/analysis/queries/go/retry_blocks.scm +14 -0
  20. nfrgate-0.1.0/src/nfr_gate/analysis/queries/go/route_definitions.scm +16 -0
  21. nfrgate-0.1.0/src/nfr_gate/analysis/queries/go/span_calls.scm +10 -0
  22. nfrgate-0.1.0/src/nfr_gate/analysis/queries/java/exception_handlers.scm +3 -0
  23. nfrgate-0.1.0/src/nfr_gate/analysis/queries/java/metric_label_calls.scm +10 -0
  24. nfrgate-0.1.0/src/nfr_gate/analysis/queries/java/outbound_calls.scm +14 -0
  25. nfrgate-0.1.0/src/nfr_gate/analysis/queries/java/retry_blocks.scm +9 -0
  26. nfrgate-0.1.0/src/nfr_gate/analysis/queries/java/route_definitions.scm +13 -0
  27. nfrgate-0.1.0/src/nfr_gate/analysis/queries/java/span_calls.scm +11 -0
  28. nfrgate-0.1.0/src/nfr_gate/analysis/queries/python/exception_handlers.scm +5 -0
  29. nfrgate-0.1.0/src/nfr_gate/analysis/queries/python/metric_label_calls.scm +11 -0
  30. nfrgate-0.1.0/src/nfr_gate/analysis/queries/python/outbound_calls.scm +12 -0
  31. nfrgate-0.1.0/src/nfr_gate/analysis/queries/python/retry_blocks.scm +14 -0
  32. nfrgate-0.1.0/src/nfr_gate/analysis/queries/python/route_definitions.scm +14 -0
  33. nfrgate-0.1.0/src/nfr_gate/analysis/queries/python/span_calls.scm +11 -0
  34. nfrgate-0.1.0/src/nfr_gate/analysis/rules/__init__.py +0 -0
  35. nfrgate-0.1.0/src/nfr_gate/analysis/rules/ast_helpers.py +227 -0
  36. nfrgate-0.1.0/src/nfr_gate/analysis/rules/context_propagation_rule.py +129 -0
  37. nfrgate-0.1.0/src/nfr_gate/analysis/rules/correlation_id_rule.py +57 -0
  38. nfrgate-0.1.0/src/nfr_gate/analysis/rules/degradation_defined_rule.py +106 -0
  39. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/__init__.py +0 -0
  40. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/ast_helpers.py +294 -0
  41. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/context_propagation_rule.py +65 -0
  42. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/correlation_id_rule.py +65 -0
  43. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/degradation_defined_rule.py +115 -0
  44. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/latency_metric_rule.py +53 -0
  45. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/log_severity_rule.py +83 -0
  46. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/metric_cardinality_rule.py +52 -0
  47. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/no_swallowed_exception_rule.py +120 -0
  48. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/outcome_metric_rule.py +51 -0
  49. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/retry_backoff_rule.py +59 -0
  50. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/span_attributes_rule.py +55 -0
  51. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/span_error_status_rule.py +61 -0
  52. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/structured_log_rule.py +76 -0
  53. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/timeout_rule.py +51 -0
  54. nfrgate-0.1.0/src/nfr_gate/analysis/rules/go/trace_span_rule.py +53 -0
  55. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/__init__.py +0 -0
  56. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/ast_helpers.py +234 -0
  57. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/context_propagation_rule.py +73 -0
  58. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/correlation_id_rule.py +55 -0
  59. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/degradation_defined_rule.py +84 -0
  60. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/latency_metric_rule.py +57 -0
  61. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/log_severity_rule.py +78 -0
  62. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/metric_cardinality_rule.py +55 -0
  63. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/no_swallowed_exception_rule.py +64 -0
  64. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/outcome_metric_rule.py +51 -0
  65. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/retry_backoff_rule.py +60 -0
  66. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/span_attributes_rule.py +57 -0
  67. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/span_error_status_rule.py +52 -0
  68. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/structured_log_rule.py +63 -0
  69. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/timeout_rule.py +55 -0
  70. nfrgate-0.1.0/src/nfr_gate/analysis/rules/java/trace_span_rule.py +50 -0
  71. nfrgate-0.1.0/src/nfr_gate/analysis/rules/latency_metric_rule.py +61 -0
  72. nfrgate-0.1.0/src/nfr_gate/analysis/rules/log_severity_rule.py +89 -0
  73. nfrgate-0.1.0/src/nfr_gate/analysis/rules/metric_cardinality_rule.py +72 -0
  74. nfrgate-0.1.0/src/nfr_gate/analysis/rules/no_swallowed_exception_rule.py +99 -0
  75. nfrgate-0.1.0/src/nfr_gate/analysis/rules/outcome_metric_rule.py +55 -0
  76. nfrgate-0.1.0/src/nfr_gate/analysis/rules/registry.py +108 -0
  77. nfrgate-0.1.0/src/nfr_gate/analysis/rules/retry_backoff_rule.py +94 -0
  78. nfrgate-0.1.0/src/nfr_gate/analysis/rules/span_attributes_rule.py +72 -0
  79. nfrgate-0.1.0/src/nfr_gate/analysis/rules/span_error_status_rule.py +54 -0
  80. nfrgate-0.1.0/src/nfr_gate/analysis/rules/structured_log_rule.py +74 -0
  81. nfrgate-0.1.0/src/nfr_gate/analysis/rules/timeout_rule.py +55 -0
  82. nfrgate-0.1.0/src/nfr_gate/analysis/rules/trace_span_rule.py +58 -0
  83. nfrgate-0.1.0/src/nfr_gate/api/__init__.py +0 -0
  84. nfrgate-0.1.0/src/nfr_gate/api/app.py +55 -0
  85. nfrgate-0.1.0/src/nfr_gate/api/auth.py +28 -0
  86. nfrgate-0.1.0/src/nfr_gate/api/routes/__init__.py +0 -0
  87. nfrgate-0.1.0/src/nfr_gate/api/routes/overrides.py +77 -0
  88. nfrgate-0.1.0/src/nfr_gate/ci/__init__.py +0 -0
  89. nfrgate-0.1.0/src/nfr_gate/ci/comment.py +139 -0
  90. nfrgate-0.1.0/src/nfr_gate/ci/git_diff.py +36 -0
  91. nfrgate-0.1.0/src/nfr_gate/ci/github_api.py +58 -0
  92. nfrgate-0.1.0/src/nfr_gate/ci/gitlab_api.py +64 -0
  93. nfrgate-0.1.0/src/nfr_gate/ci/live_assessment.py +173 -0
  94. nfrgate-0.1.0/src/nfr_gate/ci/mr_gate.py +162 -0
  95. nfrgate-0.1.0/src/nfr_gate/ci/override.py +128 -0
  96. nfrgate-0.1.0/src/nfr_gate/ci/pipeline.py +51 -0
  97. nfrgate-0.1.0/src/nfr_gate/ci/pr_gate.py +148 -0
  98. nfrgate-0.1.0/src/nfr_gate/config.py +53 -0
  99. nfrgate-0.1.0/src/nfr_gate/domain/__init__.py +0 -0
  100. nfrgate-0.1.0/src/nfr_gate/domain/models/__init__.py +12 -0
  101. nfrgate-0.1.0/src/nfr_gate/domain/models/finding.py +98 -0
  102. nfrgate-0.1.0/src/nfr_gate/domain/models/routing.py +51 -0
  103. nfrgate-0.1.0/src/nfr_gate/domain/ports/__init__.py +0 -0
  104. nfrgate-0.1.0/src/nfr_gate/domain/ports/findings_repository.py +98 -0
  105. nfrgate-0.1.0/src/nfr_gate/infra/__init__.py +0 -0
  106. nfrgate-0.1.0/src/nfr_gate/infra/fake_findings_repository.py +141 -0
  107. nfrgate-0.1.0/src/nfr_gate/infra/postgres/__init__.py +0 -0
  108. nfrgate-0.1.0/src/nfr_gate/infra/postgres/connection.py +14 -0
  109. nfrgate-0.1.0/src/nfr_gate/infra/postgres/findings_repository.py +168 -0
  110. nfrgate-0.1.0/src/nfr_gate/llm/__init__.py +0 -0
  111. nfrgate-0.1.0/src/nfr_gate/llm/assessor.py +24 -0
  112. nfrgate-0.1.0/src/nfr_gate/llm/client.py +94 -0
  113. nfrgate-0.1.0/src/nfr_gate/llm/prompt.py +86 -0
  114. nfrgate-0.1.0/src/nfr_gate/llm/response.py +54 -0
  115. nfrgate-0.1.0/src/nfr_gate/observability.py +159 -0
  116. nfrgate-0.1.0/src/nfr_gate/routing/__init__.py +0 -0
  117. nfrgate-0.1.0/src/nfr_gate/routing/policy.py +77 -0
  118. nfrgate-0.1.0/src/nfr_gate/routing/service_tiers.py +65 -0
  119. nfrgate-0.1.0/src/nfrgate.egg-info/PKG-INFO +25 -0
  120. nfrgate-0.1.0/src/nfrgate.egg-info/SOURCES.txt +130 -0
  121. nfrgate-0.1.0/src/nfrgate.egg-info/dependency_links.txt +1 -0
  122. nfrgate-0.1.0/src/nfrgate.egg-info/entry_points.txt +5 -0
  123. nfrgate-0.1.0/src/nfrgate.egg-info/requires.txt +23 -0
  124. nfrgate-0.1.0/src/nfrgate.egg-info/top_level.txt +2 -0
  125. nfrgate-0.1.0/src/rubric_store/__init__.py +0 -0
  126. nfrgate-0.1.0/src/rubric_store/definitions/logs.yaml +67 -0
  127. nfrgate-0.1.0/src/rubric_store/definitions/metrics.yaml +66 -0
  128. nfrgate-0.1.0/src/rubric_store/definitions/reliability.yaml +60 -0
  129. nfrgate-0.1.0/src/rubric_store/definitions/traces.yaml +64 -0
  130. nfrgate-0.1.0/src/rubric_store/loader.py +78 -0
  131. nfrgate-0.1.0/src/rubric_store/schema/rubric.schema.yaml +125 -0
  132. 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"
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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)
@@ -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