nfrgate 0.1.0__py3-none-any.whl
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.
- nfr_gate/__init__.py +0 -0
- nfr_gate/analysis/__init__.py +0 -0
- nfr_gate/analysis/base.py +67 -0
- nfr_gate/analysis/cli.py +83 -0
- nfr_gate/analysis/constructs.py +113 -0
- nfr_gate/analysis/diff_parser.py +73 -0
- nfr_gate/analysis/languages/__init__.py +0 -0
- nfr_gate/analysis/languages/go_analyzer.py +21 -0
- nfr_gate/analysis/languages/java_analyzer.py +21 -0
- nfr_gate/analysis/languages/python_analyzer.py +21 -0
- nfr_gate/analysis/languages/registry.py +33 -0
- nfr_gate/analysis/queries/go/error_checks.scm +22 -0
- nfr_gate/analysis/queries/go/metric_label_calls.scm +9 -0
- nfr_gate/analysis/queries/go/outbound_calls.scm +13 -0
- nfr_gate/analysis/queries/go/retry_blocks.scm +14 -0
- nfr_gate/analysis/queries/go/route_definitions.scm +16 -0
- nfr_gate/analysis/queries/go/span_calls.scm +10 -0
- nfr_gate/analysis/queries/java/exception_handlers.scm +3 -0
- nfr_gate/analysis/queries/java/metric_label_calls.scm +10 -0
- nfr_gate/analysis/queries/java/outbound_calls.scm +14 -0
- nfr_gate/analysis/queries/java/retry_blocks.scm +9 -0
- nfr_gate/analysis/queries/java/route_definitions.scm +13 -0
- nfr_gate/analysis/queries/java/span_calls.scm +11 -0
- nfr_gate/analysis/queries/python/exception_handlers.scm +5 -0
- nfr_gate/analysis/queries/python/metric_label_calls.scm +11 -0
- nfr_gate/analysis/queries/python/outbound_calls.scm +12 -0
- nfr_gate/analysis/queries/python/retry_blocks.scm +14 -0
- nfr_gate/analysis/queries/python/route_definitions.scm +14 -0
- nfr_gate/analysis/queries/python/span_calls.scm +11 -0
- nfr_gate/analysis/rules/__init__.py +0 -0
- nfr_gate/analysis/rules/ast_helpers.py +227 -0
- nfr_gate/analysis/rules/context_propagation_rule.py +129 -0
- nfr_gate/analysis/rules/correlation_id_rule.py +57 -0
- nfr_gate/analysis/rules/degradation_defined_rule.py +106 -0
- nfr_gate/analysis/rules/go/__init__.py +0 -0
- nfr_gate/analysis/rules/go/ast_helpers.py +294 -0
- nfr_gate/analysis/rules/go/context_propagation_rule.py +65 -0
- nfr_gate/analysis/rules/go/correlation_id_rule.py +65 -0
- nfr_gate/analysis/rules/go/degradation_defined_rule.py +115 -0
- nfr_gate/analysis/rules/go/latency_metric_rule.py +53 -0
- nfr_gate/analysis/rules/go/log_severity_rule.py +83 -0
- nfr_gate/analysis/rules/go/metric_cardinality_rule.py +52 -0
- nfr_gate/analysis/rules/go/no_swallowed_exception_rule.py +120 -0
- nfr_gate/analysis/rules/go/outcome_metric_rule.py +51 -0
- nfr_gate/analysis/rules/go/retry_backoff_rule.py +59 -0
- nfr_gate/analysis/rules/go/span_attributes_rule.py +55 -0
- nfr_gate/analysis/rules/go/span_error_status_rule.py +61 -0
- nfr_gate/analysis/rules/go/structured_log_rule.py +76 -0
- nfr_gate/analysis/rules/go/timeout_rule.py +51 -0
- nfr_gate/analysis/rules/go/trace_span_rule.py +53 -0
- nfr_gate/analysis/rules/java/__init__.py +0 -0
- nfr_gate/analysis/rules/java/ast_helpers.py +234 -0
- nfr_gate/analysis/rules/java/context_propagation_rule.py +73 -0
- nfr_gate/analysis/rules/java/correlation_id_rule.py +55 -0
- nfr_gate/analysis/rules/java/degradation_defined_rule.py +84 -0
- nfr_gate/analysis/rules/java/latency_metric_rule.py +57 -0
- nfr_gate/analysis/rules/java/log_severity_rule.py +78 -0
- nfr_gate/analysis/rules/java/metric_cardinality_rule.py +55 -0
- nfr_gate/analysis/rules/java/no_swallowed_exception_rule.py +64 -0
- nfr_gate/analysis/rules/java/outcome_metric_rule.py +51 -0
- nfr_gate/analysis/rules/java/retry_backoff_rule.py +60 -0
- nfr_gate/analysis/rules/java/span_attributes_rule.py +57 -0
- nfr_gate/analysis/rules/java/span_error_status_rule.py +52 -0
- nfr_gate/analysis/rules/java/structured_log_rule.py +63 -0
- nfr_gate/analysis/rules/java/timeout_rule.py +55 -0
- nfr_gate/analysis/rules/java/trace_span_rule.py +50 -0
- nfr_gate/analysis/rules/latency_metric_rule.py +61 -0
- nfr_gate/analysis/rules/log_severity_rule.py +89 -0
- nfr_gate/analysis/rules/metric_cardinality_rule.py +72 -0
- nfr_gate/analysis/rules/no_swallowed_exception_rule.py +99 -0
- nfr_gate/analysis/rules/outcome_metric_rule.py +55 -0
- nfr_gate/analysis/rules/registry.py +108 -0
- nfr_gate/analysis/rules/retry_backoff_rule.py +94 -0
- nfr_gate/analysis/rules/span_attributes_rule.py +72 -0
- nfr_gate/analysis/rules/span_error_status_rule.py +54 -0
- nfr_gate/analysis/rules/structured_log_rule.py +74 -0
- nfr_gate/analysis/rules/timeout_rule.py +55 -0
- nfr_gate/analysis/rules/trace_span_rule.py +58 -0
- nfr_gate/api/__init__.py +0 -0
- nfr_gate/api/app.py +55 -0
- nfr_gate/api/auth.py +28 -0
- nfr_gate/api/routes/__init__.py +0 -0
- nfr_gate/api/routes/overrides.py +77 -0
- nfr_gate/ci/__init__.py +0 -0
- nfr_gate/ci/comment.py +139 -0
- nfr_gate/ci/git_diff.py +36 -0
- nfr_gate/ci/github_api.py +58 -0
- nfr_gate/ci/gitlab_api.py +64 -0
- nfr_gate/ci/live_assessment.py +173 -0
- nfr_gate/ci/mr_gate.py +162 -0
- nfr_gate/ci/override.py +128 -0
- nfr_gate/ci/pipeline.py +51 -0
- nfr_gate/ci/pr_gate.py +148 -0
- nfr_gate/config.py +53 -0
- nfr_gate/domain/__init__.py +0 -0
- nfr_gate/domain/models/__init__.py +12 -0
- nfr_gate/domain/models/finding.py +98 -0
- nfr_gate/domain/models/routing.py +51 -0
- nfr_gate/domain/ports/__init__.py +0 -0
- nfr_gate/domain/ports/findings_repository.py +98 -0
- nfr_gate/infra/__init__.py +0 -0
- nfr_gate/infra/fake_findings_repository.py +141 -0
- nfr_gate/infra/postgres/__init__.py +0 -0
- nfr_gate/infra/postgres/connection.py +14 -0
- nfr_gate/infra/postgres/findings_repository.py +168 -0
- nfr_gate/llm/__init__.py +0 -0
- nfr_gate/llm/assessor.py +24 -0
- nfr_gate/llm/client.py +94 -0
- nfr_gate/llm/prompt.py +86 -0
- nfr_gate/llm/response.py +54 -0
- nfr_gate/observability.py +159 -0
- nfr_gate/routing/__init__.py +0 -0
- nfr_gate/routing/policy.py +77 -0
- nfr_gate/routing/service_tiers.py +65 -0
- nfrgate-0.1.0.dist-info/METADATA +25 -0
- nfrgate-0.1.0.dist-info/RECORD +127 -0
- nfrgate-0.1.0.dist-info/WHEEL +5 -0
- nfrgate-0.1.0.dist-info/entry_points.txt +5 -0
- nfrgate-0.1.0.dist-info/top_level.txt +2 -0
- rubric_store/__init__.py +0 -0
- rubric_store/definitions/logs.yaml +67 -0
- rubric_store/definitions/metrics.yaml +66 -0
- rubric_store/definitions/reliability.yaml +60 -0
- rubric_store/definitions/traces.yaml +64 -0
- rubric_store/loader.py +78 -0
- rubric_store/schema/rubric.schema.yaml +125 -0
- rubric_store/sync.py +89 -0
nfr_gate/__init__.py
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"."""
|
nfr_gate/analysis/cli.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
; Matches an inline HTTP handler registration: router.GET/POST/PUT/DELETE/
|
|
2
|
+
; PATCH("path", func(...) {...}) (Gin-style) or http.HandleFunc("path",
|
|
3
|
+
; func(...) {...}) (stdlib). Deliberately scoped to the INLINE-func-literal
|
|
4
|
+
; shape only — a handler registered by name reference
|
|
5
|
+
; (router.GET("/charge", chargeHandler)) is not resolved to its declaration
|
|
6
|
+
; elsewhere in the file; that needs call-graph resolution this pass doesn't
|
|
7
|
+
; have. Consumed by rules M1 (missing_latency_metric) and M2
|
|
8
|
+
; (missing_outcome_metric).
|
|
9
|
+
|
|
10
|
+
(call_expression
|
|
11
|
+
function: (selector_expression field: (field_identifier) @_method)
|
|
12
|
+
arguments: (argument_list
|
|
13
|
+
(interpreted_string_literal)
|
|
14
|
+
(func_literal) @_handler)
|
|
15
|
+
(#match? @_method "^(GET|POST|PUT|DELETE|PATCH|HandleFunc)$")
|
|
16
|
+
) @route_definition
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
; Matches `.Start(...)` calls — the same method name is_wrapped_in_span/
|
|
2
|
+
; has_preceding_call already key on for T1/R1's "was a span set up
|
|
3
|
+
; earlier in this function" check, but captured here as its own construct
|
|
4
|
+
; so T2 can inspect what happens to *this specific* span (are attributes
|
|
5
|
+
; ever set on it), independent of whether an outbound call follows it.
|
|
6
|
+
|
|
7
|
+
(call_expression
|
|
8
|
+
function: (selector_expression field: (field_identifier) @_method)
|
|
9
|
+
(#eq? @_method "Start")
|
|
10
|
+
) @span_call
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
; Matches Micrometer's `.tag(key, value)` / `.tags(k1, v1, k2, v2, ...)`
|
|
2
|
+
; calls — the standard way to attach dimensions to a metric in Java's
|
|
3
|
+
; dominant metrics library. M3 inspects the string-literal argument text
|
|
4
|
+
; for unbounded-looking keys, not the query's job — this query only finds
|
|
5
|
+
; the call.
|
|
6
|
+
|
|
7
|
+
(method_invocation
|
|
8
|
+
name: (identifier) @_method
|
|
9
|
+
(#match? @_method "^(tag|tags)$")
|
|
10
|
+
) @metric_label_call
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
; Matches "obj.method(...)"-shaped method invocations where the receiver is
|
|
2
|
+
; a plain identifier: client.send(...), httpClient.execute(...). Requiring
|
|
3
|
+
; a bare identifier (not `object: (_)`) excludes fluent builder chains
|
|
4
|
+
; (`HttpRequest.newBuilder().uri(...).POST(...).build()`) from each being
|
|
5
|
+
; flagged as a separate outbound call — matches the same restriction
|
|
6
|
+
; already applied in the Go query, for the same reason. Still a broad
|
|
7
|
+
; heuristic, not a whitelist of known HTTP/DB clients — carries the same
|
|
8
|
+
; general false-positive risk as the Python/Go versions.
|
|
9
|
+
; Consumed by rules T1 and R1.
|
|
10
|
+
|
|
11
|
+
(method_invocation
|
|
12
|
+
object: (identifier)
|
|
13
|
+
name: (identifier) @callee_method
|
|
14
|
+
) @outbound_call
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
; Matches Thread.sleep(...) — the standard Java retry-delay idiom. Consumed
|
|
2
|
+
; by rule R2 (retry_without_backoff).
|
|
3
|
+
|
|
4
|
+
(method_invocation
|
|
5
|
+
object: (identifier) @_sleep_object
|
|
6
|
+
name: (identifier) @_sleep_method
|
|
7
|
+
(#eq? @_sleep_object "Thread")
|
|
8
|
+
(#eq? @_sleep_method "sleep")
|
|
9
|
+
) @sleep_call
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
; Matches a Spring-style route handler: a method_declaration annotated with
|
|
2
|
+
; @GetMapping/@PostMapping/@PutMapping/@DeleteMapping/@PatchMapping/
|
|
3
|
+
; @RequestMapping, with or without a path argument (marker_annotation vs.
|
|
4
|
+
; annotation — Spring allows a bare @GetMapping mapping to the class-level
|
|
5
|
+
; @RequestMapping's base path). Consumed by rules M1 (missing_latency_metric)
|
|
6
|
+
; and M2 (missing_outcome_metric), mirroring python/route_definitions.scm.
|
|
7
|
+
|
|
8
|
+
(method_declaration
|
|
9
|
+
(modifiers
|
|
10
|
+
[(annotation name: (identifier) @_annotation_name)
|
|
11
|
+
(marker_annotation name: (identifier) @_annotation_name)])
|
|
12
|
+
(#match? @_annotation_name "^(GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|RequestMapping)$")
|
|
13
|
+
) @route_definition
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
; Matches `.startSpan()` — the call that actually creates a span in Java's
|
|
2
|
+
; OTel API (`tracer.spanBuilder("name").startSpan()`), as distinct from
|
|
3
|
+
; is_wrapped_in_span's textual check on the enclosing try-with-resources's
|
|
4
|
+
; `makeCurrent()` call. T2 needs to inspect what happens around *this*
|
|
5
|
+
; call (is an attribute ever set on the resulting span), which
|
|
6
|
+
; outbound_calls.scm's capture doesn't give direct access to.
|
|
7
|
+
|
|
8
|
+
(method_invocation
|
|
9
|
+
name: (identifier) @_method
|
|
10
|
+
(#eq? @_method "startSpan")
|
|
11
|
+
) @span_call
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
; Matches `.labels(...)` / `.tags(...)` calls — the standard way both
|
|
2
|
+
; prometheus_client and most StatsD-style clients attach dimensions to a
|
|
3
|
+
; metric. M3 inspects the keyword argument *names* passed here for
|
|
4
|
+
; unbounded-looking fields (user_id, email, a raw URL, ...), not the query's
|
|
5
|
+
; job — this query only finds the call.
|
|
6
|
+
|
|
7
|
+
(call
|
|
8
|
+
function: (attribute
|
|
9
|
+
attribute: (identifier) @_method)
|
|
10
|
+
(#match? @_method "^(labels|tags)$")
|
|
11
|
+
) @metric_label_call
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
; Matches "obj.method(...)"-shaped calls: requests.get(...), httpx.post(...),
|
|
2
|
+
; db_client.execute(...), payment_client.charge(...). This is a heuristic,
|
|
3
|
+
; not a whitelist of known HTTP/DB clients — bare function calls (print(),
|
|
4
|
+
; len()) are excluded by requiring an attribute-access callee, which is
|
|
5
|
+
; deliberately broad for v1 rather than tuned per-library. Consumed by rules
|
|
6
|
+
; T1 (trace_span_missing) and R1 (missing_timeout) per rubric_store/definitions.
|
|
7
|
+
|
|
8
|
+
(call
|
|
9
|
+
function: (attribute
|
|
10
|
+
object: (identifier) @callee_object
|
|
11
|
+
attribute: (identifier) @callee_method)
|
|
12
|
+
) @outbound_call
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
; Matches calls to time.sleep(...) / asyncio.sleep(...) — the delay
|
|
2
|
+
; mechanism a retry loop uses between attempts. Consumed by rule R2
|
|
3
|
+
; (retry_without_backoff). Whether this is actually inside a retry loop
|
|
4
|
+
; (vs. e.g. a one-off startup delay) and what the delay argument's shape
|
|
5
|
+
; implies about backoff is decided in Python by walking the node directly
|
|
6
|
+
; (see analysis/rules/retry_backoff_rule.py) — this query's only job is to
|
|
7
|
+
; find candidate sleep calls.
|
|
8
|
+
|
|
9
|
+
(call
|
|
10
|
+
function: (attribute
|
|
11
|
+
object: (identifier) @_sleep_object
|
|
12
|
+
attribute: (identifier) @_sleep_method)
|
|
13
|
+
(#match? @_sleep_method "^sleep$")
|
|
14
|
+
) @sleep_call
|