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.
Files changed (127) hide show
  1. nfr_gate/__init__.py +0 -0
  2. nfr_gate/analysis/__init__.py +0 -0
  3. nfr_gate/analysis/base.py +67 -0
  4. nfr_gate/analysis/cli.py +83 -0
  5. nfr_gate/analysis/constructs.py +113 -0
  6. nfr_gate/analysis/diff_parser.py +73 -0
  7. nfr_gate/analysis/languages/__init__.py +0 -0
  8. nfr_gate/analysis/languages/go_analyzer.py +21 -0
  9. nfr_gate/analysis/languages/java_analyzer.py +21 -0
  10. nfr_gate/analysis/languages/python_analyzer.py +21 -0
  11. nfr_gate/analysis/languages/registry.py +33 -0
  12. nfr_gate/analysis/queries/go/error_checks.scm +22 -0
  13. nfr_gate/analysis/queries/go/metric_label_calls.scm +9 -0
  14. nfr_gate/analysis/queries/go/outbound_calls.scm +13 -0
  15. nfr_gate/analysis/queries/go/retry_blocks.scm +14 -0
  16. nfr_gate/analysis/queries/go/route_definitions.scm +16 -0
  17. nfr_gate/analysis/queries/go/span_calls.scm +10 -0
  18. nfr_gate/analysis/queries/java/exception_handlers.scm +3 -0
  19. nfr_gate/analysis/queries/java/metric_label_calls.scm +10 -0
  20. nfr_gate/analysis/queries/java/outbound_calls.scm +14 -0
  21. nfr_gate/analysis/queries/java/retry_blocks.scm +9 -0
  22. nfr_gate/analysis/queries/java/route_definitions.scm +13 -0
  23. nfr_gate/analysis/queries/java/span_calls.scm +11 -0
  24. nfr_gate/analysis/queries/python/exception_handlers.scm +5 -0
  25. nfr_gate/analysis/queries/python/metric_label_calls.scm +11 -0
  26. nfr_gate/analysis/queries/python/outbound_calls.scm +12 -0
  27. nfr_gate/analysis/queries/python/retry_blocks.scm +14 -0
  28. nfr_gate/analysis/queries/python/route_definitions.scm +14 -0
  29. nfr_gate/analysis/queries/python/span_calls.scm +11 -0
  30. nfr_gate/analysis/rules/__init__.py +0 -0
  31. nfr_gate/analysis/rules/ast_helpers.py +227 -0
  32. nfr_gate/analysis/rules/context_propagation_rule.py +129 -0
  33. nfr_gate/analysis/rules/correlation_id_rule.py +57 -0
  34. nfr_gate/analysis/rules/degradation_defined_rule.py +106 -0
  35. nfr_gate/analysis/rules/go/__init__.py +0 -0
  36. nfr_gate/analysis/rules/go/ast_helpers.py +294 -0
  37. nfr_gate/analysis/rules/go/context_propagation_rule.py +65 -0
  38. nfr_gate/analysis/rules/go/correlation_id_rule.py +65 -0
  39. nfr_gate/analysis/rules/go/degradation_defined_rule.py +115 -0
  40. nfr_gate/analysis/rules/go/latency_metric_rule.py +53 -0
  41. nfr_gate/analysis/rules/go/log_severity_rule.py +83 -0
  42. nfr_gate/analysis/rules/go/metric_cardinality_rule.py +52 -0
  43. nfr_gate/analysis/rules/go/no_swallowed_exception_rule.py +120 -0
  44. nfr_gate/analysis/rules/go/outcome_metric_rule.py +51 -0
  45. nfr_gate/analysis/rules/go/retry_backoff_rule.py +59 -0
  46. nfr_gate/analysis/rules/go/span_attributes_rule.py +55 -0
  47. nfr_gate/analysis/rules/go/span_error_status_rule.py +61 -0
  48. nfr_gate/analysis/rules/go/structured_log_rule.py +76 -0
  49. nfr_gate/analysis/rules/go/timeout_rule.py +51 -0
  50. nfr_gate/analysis/rules/go/trace_span_rule.py +53 -0
  51. nfr_gate/analysis/rules/java/__init__.py +0 -0
  52. nfr_gate/analysis/rules/java/ast_helpers.py +234 -0
  53. nfr_gate/analysis/rules/java/context_propagation_rule.py +73 -0
  54. nfr_gate/analysis/rules/java/correlation_id_rule.py +55 -0
  55. nfr_gate/analysis/rules/java/degradation_defined_rule.py +84 -0
  56. nfr_gate/analysis/rules/java/latency_metric_rule.py +57 -0
  57. nfr_gate/analysis/rules/java/log_severity_rule.py +78 -0
  58. nfr_gate/analysis/rules/java/metric_cardinality_rule.py +55 -0
  59. nfr_gate/analysis/rules/java/no_swallowed_exception_rule.py +64 -0
  60. nfr_gate/analysis/rules/java/outcome_metric_rule.py +51 -0
  61. nfr_gate/analysis/rules/java/retry_backoff_rule.py +60 -0
  62. nfr_gate/analysis/rules/java/span_attributes_rule.py +57 -0
  63. nfr_gate/analysis/rules/java/span_error_status_rule.py +52 -0
  64. nfr_gate/analysis/rules/java/structured_log_rule.py +63 -0
  65. nfr_gate/analysis/rules/java/timeout_rule.py +55 -0
  66. nfr_gate/analysis/rules/java/trace_span_rule.py +50 -0
  67. nfr_gate/analysis/rules/latency_metric_rule.py +61 -0
  68. nfr_gate/analysis/rules/log_severity_rule.py +89 -0
  69. nfr_gate/analysis/rules/metric_cardinality_rule.py +72 -0
  70. nfr_gate/analysis/rules/no_swallowed_exception_rule.py +99 -0
  71. nfr_gate/analysis/rules/outcome_metric_rule.py +55 -0
  72. nfr_gate/analysis/rules/registry.py +108 -0
  73. nfr_gate/analysis/rules/retry_backoff_rule.py +94 -0
  74. nfr_gate/analysis/rules/span_attributes_rule.py +72 -0
  75. nfr_gate/analysis/rules/span_error_status_rule.py +54 -0
  76. nfr_gate/analysis/rules/structured_log_rule.py +74 -0
  77. nfr_gate/analysis/rules/timeout_rule.py +55 -0
  78. nfr_gate/analysis/rules/trace_span_rule.py +58 -0
  79. nfr_gate/api/__init__.py +0 -0
  80. nfr_gate/api/app.py +55 -0
  81. nfr_gate/api/auth.py +28 -0
  82. nfr_gate/api/routes/__init__.py +0 -0
  83. nfr_gate/api/routes/overrides.py +77 -0
  84. nfr_gate/ci/__init__.py +0 -0
  85. nfr_gate/ci/comment.py +139 -0
  86. nfr_gate/ci/git_diff.py +36 -0
  87. nfr_gate/ci/github_api.py +58 -0
  88. nfr_gate/ci/gitlab_api.py +64 -0
  89. nfr_gate/ci/live_assessment.py +173 -0
  90. nfr_gate/ci/mr_gate.py +162 -0
  91. nfr_gate/ci/override.py +128 -0
  92. nfr_gate/ci/pipeline.py +51 -0
  93. nfr_gate/ci/pr_gate.py +148 -0
  94. nfr_gate/config.py +53 -0
  95. nfr_gate/domain/__init__.py +0 -0
  96. nfr_gate/domain/models/__init__.py +12 -0
  97. nfr_gate/domain/models/finding.py +98 -0
  98. nfr_gate/domain/models/routing.py +51 -0
  99. nfr_gate/domain/ports/__init__.py +0 -0
  100. nfr_gate/domain/ports/findings_repository.py +98 -0
  101. nfr_gate/infra/__init__.py +0 -0
  102. nfr_gate/infra/fake_findings_repository.py +141 -0
  103. nfr_gate/infra/postgres/__init__.py +0 -0
  104. nfr_gate/infra/postgres/connection.py +14 -0
  105. nfr_gate/infra/postgres/findings_repository.py +168 -0
  106. nfr_gate/llm/__init__.py +0 -0
  107. nfr_gate/llm/assessor.py +24 -0
  108. nfr_gate/llm/client.py +94 -0
  109. nfr_gate/llm/prompt.py +86 -0
  110. nfr_gate/llm/response.py +54 -0
  111. nfr_gate/observability.py +159 -0
  112. nfr_gate/routing/__init__.py +0 -0
  113. nfr_gate/routing/policy.py +77 -0
  114. nfr_gate/routing/service_tiers.py +65 -0
  115. nfrgate-0.1.0.dist-info/METADATA +25 -0
  116. nfrgate-0.1.0.dist-info/RECORD +127 -0
  117. nfrgate-0.1.0.dist-info/WHEEL +5 -0
  118. nfrgate-0.1.0.dist-info/entry_points.txt +5 -0
  119. nfrgate-0.1.0.dist-info/top_level.txt +2 -0
  120. rubric_store/__init__.py +0 -0
  121. rubric_store/definitions/logs.yaml +67 -0
  122. rubric_store/definitions/metrics.yaml +66 -0
  123. rubric_store/definitions/reliability.yaml +60 -0
  124. rubric_store/definitions/traces.yaml +64 -0
  125. rubric_store/loader.py +78 -0
  126. rubric_store/schema/rubric.schema.yaml +125 -0
  127. 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"."""
@@ -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,3 @@
1
+ ; Every catch clause, empty or not. Consumed by rule L2 (structured logging).
2
+
3
+ (catch_clause) @catch_block
@@ -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,5 @@
1
+ ; Matches every except clause, empty or not. Whether it actually logs is the
2
+ ; rule evaluator's job (L1: no_swallowed_exception), not the query's — this
3
+ ; query's only job is to find the construct.
4
+
5
+ (except_clause) @catch_block
@@ -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