finefettle 1.0.2__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.
- fettle/__init__.py +3 -0
- fettle/__main__.py +7 -0
- fettle/_resources.py +45 -0
- fettle/_rules/.ruff.toml +10 -0
- fettle/_rules/go-antipatterns.yml +68 -0
- fettle/_rules/llm-antipatterns.yml +261 -0
- fettle/_rules/ts-antipatterns.yml +112 -0
- fettle/action_entrypoint.py +130 -0
- fettle/adapters/__init__.py +74 -0
- fettle/adapters/go_adapter.py +136 -0
- fettle/adapters/python_adapter.py +204 -0
- fettle/adapters/rust_adapter.py +122 -0
- fettle/adapters/typescript_adapter.py +182 -0
- fettle/advisory.py +136 -0
- fettle/ai_summaries.py +38 -0
- fettle/artifact_gate.py +174 -0
- fettle/autofix.py +116 -0
- fettle/baseline.py +113 -0
- fettle/bash_audit.py +86 -0
- fettle/bench.py +117 -0
- fettle/blackduck_adapter.py +133 -0
- fettle/boundary_rules.py +105 -0
- fettle/boundary_scan.py +207 -0
- fettle/cache.py +105 -0
- fettle/changeset.py +141 -0
- fettle/ci.py +212 -0
- fettle/ci_diagnose.py +137 -0
- fettle/ci_ingest.py +105 -0
- fettle/cli.py +435 -0
- fettle/commit_message.py +184 -0
- fettle/complexity_check.py +177 -0
- fettle/config.py +316 -0
- fettle/config_protect.py +194 -0
- fettle/config_v050.py +139 -0
- fettle/coverage_gate.py +228 -0
- fettle/cross_review.py +106 -0
- fettle/debt_report.py +179 -0
- fettle/dep_check.py +251 -0
- fettle/deploy_gate.py +164 -0
- fettle/destructive_guard.py +210 -0
- fettle/discipline_link.py +114 -0
- fettle/dispatcher.py +171 -0
- fettle/dispatcher_aggregate.py +107 -0
- fettle/dispatcher_registry.py +257 -0
- fettle/dispatcher_types.py +128 -0
- fettle/doctor.py +98 -0
- fettle/entry_points.py +109 -0
- fettle/environment.py +141 -0
- fettle/evals_runner.py +203 -0
- fettle/event.py +105 -0
- fettle/explain.py +84 -0
- fettle/finding.py +244 -0
- fettle/fp_stamp.py +111 -0
- fettle/health_telemetry.py +267 -0
- fettle/import_graph.py +260 -0
- fettle/install.py +156 -0
- fettle/integration_base.py +63 -0
- fettle/lean_debt.py +98 -0
- fettle/lean_sniffers.py +647 -0
- fettle/learn.py +220 -0
- fettle/loop_detect.py +232 -0
- fettle/lsp_server.py +382 -0
- fettle/mcp_trust_gate.py +333 -0
- fettle/migration_safety.py +44 -0
- fettle/mutation_test.py +217 -0
- fettle/pact_adapter.py +115 -0
- fettle/paths.py +109 -0
- fettle/plan_validator.py +333 -0
- fettle/policy_layers.py +346 -0
- fettle/post_bash_doc_check.py +185 -0
- fettle/post_edit.py +455 -0
- fettle/post_edit_go.py +120 -0
- fettle/post_edit_ts.py +185 -0
- fettle/pr_review.py +148 -0
- fettle/profile.py +260 -0
- fettle/project_rules.py +85 -0
- fettle/provenance_gate.py +141 -0
- fettle/py.typed +0 -0
- fettle/quality_gate.py +591 -0
- fettle/quality_scan.py +374 -0
- fettle/ratchet.py +377 -0
- fettle/release_gate.py +103 -0
- fettle/report.py +161 -0
- fettle/result.py +136 -0
- fettle/review.py +130 -0
- fettle/sarif.py +101 -0
- fettle/schema_drift.py +45 -0
- fettle/scope_creep.py +186 -0
- fettle/secret_scan.py +123 -0
- fettle/security_review.py +195 -0
- fettle/semgrep_util.py +75 -0
- fettle/sonar_adapter.py +113 -0
- fettle/spec_audit.py +75 -0
- fettle/stop_quality_gate.py +285 -0
- fettle/suppressions_v3.py +343 -0
- fettle/tdd_gate.py +192 -0
- fettle/test_discovery.py +91 -0
- fettle/test_runner_opts.py +61 -0
- fettle/threat_model.py +168 -0
- fettle/tier_routing.py +87 -0
- fettle/tool_runner.py +101 -0
- fettle/trace.py +81 -0
- fettle/trace_requirements.py +207 -0
- fettle/ux_spec_gate.py +90 -0
- fettle/worklog.py +165 -0
- fettle/workspace.py +234 -0
- finefettle-1.0.2.dist-info/METADATA +387 -0
- finefettle-1.0.2.dist-info/RECORD +112 -0
- finefettle-1.0.2.dist-info/WHEEL +5 -0
- finefettle-1.0.2.dist-info/entry_points.txt +2 -0
- finefettle-1.0.2.dist-info/licenses/LICENSE +21 -0
- finefettle-1.0.2.dist-info/top_level.txt +1 -0
fettle/__init__.py
ADDED
fettle/__main__.py
ADDED
fettle/_resources.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Runtime resource location for fettle.
|
|
2
|
+
|
|
3
|
+
Resolves paths to bundled resources (rules/, templates/, etc.) that work in
|
|
4
|
+
both install modes:
|
|
5
|
+
|
|
6
|
+
1. Clone-into-plugins: rules/ lives at repo root (../rules relative to scripts/)
|
|
7
|
+
2. pip/uvx install: rules/ is copied into the wheel as fettle/_rules/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_PACKAGE_DIR = Path(__file__).parent
|
|
16
|
+
_REPO_ROOT = _PACKAGE_DIR.parent # Only valid in clone mode
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def rules_dir() -> Path:
|
|
20
|
+
"""Return the path to the semgrep/ruff rules directory.
|
|
21
|
+
|
|
22
|
+
Resolution order:
|
|
23
|
+
1. CLAUDE_PLUGIN_ROOT env var (explicit override)
|
|
24
|
+
2. Clone-mode: repo_root/rules/ (exists when running from git clone)
|
|
25
|
+
3. Pip-installed: package/_rules/ (bundled inside the wheel)
|
|
26
|
+
"""
|
|
27
|
+
# 1. Explicit env var
|
|
28
|
+
env_root = os.environ.get("CLAUDE_PLUGIN_ROOT")
|
|
29
|
+
if env_root:
|
|
30
|
+
candidate = Path(env_root) / "rules"
|
|
31
|
+
if candidate.is_dir():
|
|
32
|
+
return candidate
|
|
33
|
+
|
|
34
|
+
# 2. Clone mode — rules/ at repo root
|
|
35
|
+
clone_rules = _REPO_ROOT / "rules"
|
|
36
|
+
if clone_rules.is_dir():
|
|
37
|
+
return clone_rules
|
|
38
|
+
|
|
39
|
+
# 3. Pip-installed — _rules/ inside the package
|
|
40
|
+
pkg_rules = _PACKAGE_DIR / "_rules"
|
|
41
|
+
if pkg_rules.is_dir():
|
|
42
|
+
return pkg_rules
|
|
43
|
+
|
|
44
|
+
# Fallback to clone path (will fail gracefully downstream)
|
|
45
|
+
return clone_rules
|
fettle/_rules/.ruff.toml
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
rules:
|
|
2
|
+
- id: empty-error-swallow-go
|
|
3
|
+
metadata:
|
|
4
|
+
origin: fettle-v0.4.2
|
|
5
|
+
citation: "Swallowed errors are the Go twin of bare-except-pass — failures vanish silently"
|
|
6
|
+
fix: 'Handle it: return fmt.Errorf("context: %w", err) or log and recover deliberately'
|
|
7
|
+
languages: [go]
|
|
8
|
+
severity: ERROR
|
|
9
|
+
pattern-either:
|
|
10
|
+
- pattern: |
|
|
11
|
+
if $ERR != nil { }
|
|
12
|
+
- pattern: |
|
|
13
|
+
if $ERR := $F(...); $ERR != nil { }
|
|
14
|
+
message: "Error checked but swallowed — empty if-block hides failures."
|
|
15
|
+
|
|
16
|
+
- id: debug-print-go
|
|
17
|
+
metadata:
|
|
18
|
+
origin: debug-detection
|
|
19
|
+
citation: "Leftover fmt.Println pollutes service logs — use the structured logger"
|
|
20
|
+
fix: 'Replace with slog/zap: logger.Info(...) — or remove'
|
|
21
|
+
languages: [go]
|
|
22
|
+
severity: WARNING
|
|
23
|
+
pattern-either:
|
|
24
|
+
- pattern: fmt.Println(...)
|
|
25
|
+
- pattern: fmt.Printf(...)
|
|
26
|
+
- pattern: println(...)
|
|
27
|
+
paths:
|
|
28
|
+
exclude:
|
|
29
|
+
- "cmd/"
|
|
30
|
+
- "tools/"
|
|
31
|
+
- "scripts/"
|
|
32
|
+
- "**/main.go"
|
|
33
|
+
- "**/*_test.go"
|
|
34
|
+
message: "Leftover debug print — use the structured logger or remove."
|
|
35
|
+
|
|
36
|
+
- id: sql-string-concat-go
|
|
37
|
+
metadata:
|
|
38
|
+
origin: security
|
|
39
|
+
citation: "OWASP: SQL injection via string-built queries"
|
|
40
|
+
fix: 'Use placeholders: db.Query("SELECT ... WHERE id = $1", id)'
|
|
41
|
+
languages: [go]
|
|
42
|
+
severity: ERROR
|
|
43
|
+
pattern-either:
|
|
44
|
+
- pattern: $DB.Query("..." + $VAR, ...)
|
|
45
|
+
- pattern: $DB.QueryRow("..." + $VAR, ...)
|
|
46
|
+
- pattern: $DB.Exec("..." + $VAR, ...)
|
|
47
|
+
- pattern: $DB.QueryContext($CTX, "..." + $VAR, ...)
|
|
48
|
+
- pattern: $DB.QueryRowContext($CTX, "..." + $VAR, ...)
|
|
49
|
+
- pattern: $DB.ExecContext($CTX, "..." + $VAR, ...)
|
|
50
|
+
- pattern: fmt.Sprintf("SELECT ...", ...)
|
|
51
|
+
- pattern: fmt.Sprintf("INSERT ...", ...)
|
|
52
|
+
- pattern: fmt.Sprintf("UPDATE ...", ...)
|
|
53
|
+
- pattern: fmt.Sprintf("DELETE ...", ...)
|
|
54
|
+
message: "SQL built from strings is injection-vulnerable — use parameterized queries."
|
|
55
|
+
|
|
56
|
+
- id: http-client-no-timeout-go
|
|
57
|
+
metadata:
|
|
58
|
+
origin: fettle-v0.4.2
|
|
59
|
+
citation: "http.Client zero value has no timeout — hangs on unresponsive servers"
|
|
60
|
+
fix: 'http.Client{Timeout: 30 * time.Second}'
|
|
61
|
+
languages: [go]
|
|
62
|
+
severity: WARNING
|
|
63
|
+
patterns:
|
|
64
|
+
- pattern: |
|
|
65
|
+
http.Client{...}
|
|
66
|
+
- pattern-not: |
|
|
67
|
+
http.Client{..., Timeout: $T, ...}
|
|
68
|
+
message: "http.Client without Timeout can hang forever — set an explicit Timeout."
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
rules:
|
|
2
|
+
- id: regex-llm-output
|
|
3
|
+
metadata:
|
|
4
|
+
origin: llm-antipattern
|
|
5
|
+
citation: "Structured tool_use replaced regex parsing of model output across our agents"
|
|
6
|
+
languages: [python]
|
|
7
|
+
severity: ERROR
|
|
8
|
+
paths:
|
|
9
|
+
include:
|
|
10
|
+
- "pipeline/"
|
|
11
|
+
- "agents/"
|
|
12
|
+
patterns:
|
|
13
|
+
- pattern-either:
|
|
14
|
+
- pattern: re.search($PATTERN, ...)
|
|
15
|
+
- pattern: re.findall($PATTERN, ...)
|
|
16
|
+
- metavariable-regex:
|
|
17
|
+
metavariable: $PATTERN
|
|
18
|
+
regex: ".*[\\[\\{].*"
|
|
19
|
+
message: >-
|
|
20
|
+
Regex parsing of LLM output is fragile and produces invalid results.
|
|
21
|
+
Fix: Use Anthropic tool_use for structured output, or 'pip install instructor'
|
|
22
|
+
and use Instructor with Pydantic models.
|
|
23
|
+
Example: response = client.messages.create(..., tools=[tool_def])
|
|
24
|
+
|
|
25
|
+
- id: bare-except-swallow
|
|
26
|
+
metadata:
|
|
27
|
+
origin: classic
|
|
28
|
+
citation: "Swallowed exceptions repeatedly hid real failures in AI-generated code"
|
|
29
|
+
languages: [python]
|
|
30
|
+
severity: ERROR
|
|
31
|
+
patterns:
|
|
32
|
+
- pattern: |
|
|
33
|
+
try:
|
|
34
|
+
...
|
|
35
|
+
except:
|
|
36
|
+
pass
|
|
37
|
+
message: >-
|
|
38
|
+
Bare except with pass swallows all errors silently.
|
|
39
|
+
Fix: Catch specific exceptions: 'except (ValueError, KeyError) as e:
|
|
40
|
+
logger.warning("Handled: %s", e)'
|
|
41
|
+
|
|
42
|
+
- id: broad-except-no-reraise
|
|
43
|
+
metadata:
|
|
44
|
+
origin: classic
|
|
45
|
+
citation: "Broad catches without logging made production failures invisible"
|
|
46
|
+
languages: [python]
|
|
47
|
+
severity: ERROR
|
|
48
|
+
patterns:
|
|
49
|
+
- pattern-either:
|
|
50
|
+
- pattern: |
|
|
51
|
+
try:
|
|
52
|
+
...
|
|
53
|
+
except Exception as $E:
|
|
54
|
+
$BODY
|
|
55
|
+
- pattern: |
|
|
56
|
+
try:
|
|
57
|
+
...
|
|
58
|
+
except BaseException as $E:
|
|
59
|
+
$BODY
|
|
60
|
+
- pattern-not: |
|
|
61
|
+
try:
|
|
62
|
+
...
|
|
63
|
+
except Exception as $E:
|
|
64
|
+
...
|
|
65
|
+
raise
|
|
66
|
+
- pattern-not: |
|
|
67
|
+
try:
|
|
68
|
+
...
|
|
69
|
+
except Exception as $E:
|
|
70
|
+
...
|
|
71
|
+
raise ...
|
|
72
|
+
- pattern-not: |
|
|
73
|
+
try:
|
|
74
|
+
...
|
|
75
|
+
except BaseException as $E:
|
|
76
|
+
...
|
|
77
|
+
raise
|
|
78
|
+
- pattern-not: |
|
|
79
|
+
try:
|
|
80
|
+
...
|
|
81
|
+
except BaseException as $E:
|
|
82
|
+
...
|
|
83
|
+
raise ...
|
|
84
|
+
- pattern-not: |
|
|
85
|
+
try:
|
|
86
|
+
...
|
|
87
|
+
except Exception as $E:
|
|
88
|
+
...
|
|
89
|
+
logging.$METHOD(...)
|
|
90
|
+
- pattern-not: |
|
|
91
|
+
try:
|
|
92
|
+
...
|
|
93
|
+
except BaseException as $E:
|
|
94
|
+
...
|
|
95
|
+
logging.$METHOD(...)
|
|
96
|
+
- pattern-not: |
|
|
97
|
+
try:
|
|
98
|
+
...
|
|
99
|
+
except Exception as $E:
|
|
100
|
+
...
|
|
101
|
+
logger.$METHOD(...)
|
|
102
|
+
- pattern-not: |
|
|
103
|
+
try:
|
|
104
|
+
...
|
|
105
|
+
except BaseException as $E:
|
|
106
|
+
...
|
|
107
|
+
logger.$METHOD(...)
|
|
108
|
+
message: >-
|
|
109
|
+
Broad Exception caught without re-raise or logging hides failures.
|
|
110
|
+
Fix: Add 'logger.error("Failed: %s", e, exc_info=True)' or catch specific exceptions.
|
|
111
|
+
|
|
112
|
+
- id: missing-httpx-timeout
|
|
113
|
+
metadata:
|
|
114
|
+
origin: llm-antipattern
|
|
115
|
+
citation: "AI-generated HTTP clients repeatedly shipped without timeouts"
|
|
116
|
+
languages: [python]
|
|
117
|
+
severity: ERROR
|
|
118
|
+
patterns:
|
|
119
|
+
- pattern-either:
|
|
120
|
+
- pattern: httpx.AsyncClient(...)
|
|
121
|
+
- pattern: httpx.Client(...)
|
|
122
|
+
- pattern-not: httpx.AsyncClient(..., timeout=..., ...)
|
|
123
|
+
- pattern-not: httpx.Client(..., timeout=..., ...)
|
|
124
|
+
message: >-
|
|
125
|
+
httpx client without timeout will hang on unresponsive servers.
|
|
126
|
+
Fix: httpx.AsyncClient(timeout=httpx.Timeout(30.0))
|
|
127
|
+
|
|
128
|
+
- id: sql-fstring
|
|
129
|
+
metadata:
|
|
130
|
+
origin: security
|
|
131
|
+
citation: "OWASP: SQL injection via string-built queries"
|
|
132
|
+
languages: [python]
|
|
133
|
+
severity: ERROR
|
|
134
|
+
patterns:
|
|
135
|
+
- pattern-regex: "(?i)f[\"'].*(?:SELECT|INSERT|UPDATE|DELETE|DROP)\\s.*\\{.*\\}.*[\"']"
|
|
136
|
+
message: >-
|
|
137
|
+
SQL via f-string is injection-vulnerable.
|
|
138
|
+
Fix: Use parameterized queries: cursor.execute('SELECT * FROM table WHERE id = ?', (user_id,))
|
|
139
|
+
|
|
140
|
+
- id: datetime-now-pipeline
|
|
141
|
+
metadata:
|
|
142
|
+
origin: pipeline
|
|
143
|
+
citation: "Wall-clock reads in pipelines broke historical backfills"
|
|
144
|
+
languages: [python]
|
|
145
|
+
severity: WARNING
|
|
146
|
+
paths:
|
|
147
|
+
include:
|
|
148
|
+
- "pipeline/"
|
|
149
|
+
- "sources/"
|
|
150
|
+
pattern-either:
|
|
151
|
+
- pattern: datetime.now()
|
|
152
|
+
- pattern: datetime.datetime.now()
|
|
153
|
+
message: >-
|
|
154
|
+
datetime.now() in pipeline code breaks backfill and testing.
|
|
155
|
+
Fix: Accept a 'clock: Callable[[], datetime] = datetime.now' parameter
|
|
156
|
+
and call clock() instead.
|
|
157
|
+
|
|
158
|
+
- id: health-score-inversion
|
|
159
|
+
metadata:
|
|
160
|
+
origin: incident
|
|
161
|
+
citation: "INC-2026-0501-A: health dimension returned 1.0 on absent data, masking an outage"
|
|
162
|
+
languages: [python]
|
|
163
|
+
severity: ERROR
|
|
164
|
+
pattern-either:
|
|
165
|
+
- pattern: |
|
|
166
|
+
if $X <= 0.0:
|
|
167
|
+
return 1.0
|
|
168
|
+
- pattern: |
|
|
169
|
+
if $X <= 0:
|
|
170
|
+
return 1.0
|
|
171
|
+
- pattern: |
|
|
172
|
+
if $X == 0.0:
|
|
173
|
+
return 1.0
|
|
174
|
+
- pattern: |
|
|
175
|
+
if $X == 0:
|
|
176
|
+
return 1.0
|
|
177
|
+
- pattern: |
|
|
178
|
+
if not $X:
|
|
179
|
+
return 1.0
|
|
180
|
+
- pattern: return 1.0 if $X <= 0.0 else $Y
|
|
181
|
+
- pattern: return 1.0 if $X <= 0 else $Y
|
|
182
|
+
- pattern: return 1.0 if not $X else $Y
|
|
183
|
+
message: >-
|
|
184
|
+
Health score returns 1.0 (perfect) when metric is zero or absent.
|
|
185
|
+
"No data" must never map to the best possible score — it must return
|
|
186
|
+
None (unmeasured) or 0.0 (degraded), not 1.0.
|
|
187
|
+
Fix: return None when no measurement is available; let the composite
|
|
188
|
+
skip unmeasured dimensions and set status=unknown.
|
|
189
|
+
Incident: incident INC-2026-0501-A.responsiveness,
|
|
190
|
+
KHI.accuracy, KHI.behavioral_consistency all had this pattern.
|
|
191
|
+
|
|
192
|
+
- id: orphaned-queue-flag
|
|
193
|
+
metadata:
|
|
194
|
+
origin: incident
|
|
195
|
+
citation: "INC-2026-0501-B: write-only queue shipped with no verified consumer"
|
|
196
|
+
languages: [python]
|
|
197
|
+
severity: ERROR
|
|
198
|
+
pattern-regex: "(?m)processed\\s*=\\s*0(?!.*#\\s*fettle:queue-consumer-verified)"
|
|
199
|
+
message: >-
|
|
200
|
+
Queue flag (processed=0) written without a fettle:queue-consumer-verified
|
|
201
|
+
annotation. This risks a write-only queue — rows accumulate with no
|
|
202
|
+
consumer to process them.
|
|
203
|
+
Fix: ensure a consumer exists that calls get_unprocessed_* and marks
|
|
204
|
+
rows processed=1, then annotate the write site:
|
|
205
|
+
# fettle:queue-consumer-verified consumer=<consumer_file.py>
|
|
206
|
+
Incident: incident INC-2026-0501-B
|
|
207
|
+
written with processed=0; no consumer ever called get_unprocessed_episodes.
|
|
208
|
+
|
|
209
|
+
- id: debug-breakpoint
|
|
210
|
+
metadata:
|
|
211
|
+
origin: debug-detection
|
|
212
|
+
citation: "WP-113: Leftover debug statements leak into production"
|
|
213
|
+
languages: [python]
|
|
214
|
+
severity: WARNING
|
|
215
|
+
pattern: breakpoint()
|
|
216
|
+
message: "Leftover breakpoint() — remove before shipping."
|
|
217
|
+
|
|
218
|
+
- id: debug-pdb
|
|
219
|
+
metadata:
|
|
220
|
+
origin: debug-detection
|
|
221
|
+
citation: "WP-113: Leftover debug statements leak into production"
|
|
222
|
+
languages: [python]
|
|
223
|
+
severity: WARNING
|
|
224
|
+
pattern-either:
|
|
225
|
+
- pattern: import pdb
|
|
226
|
+
- pattern: pdb.set_trace()
|
|
227
|
+
message: "Leftover pdb debugger — remove before shipping."
|
|
228
|
+
|
|
229
|
+
- id: debug-print-statement
|
|
230
|
+
metadata:
|
|
231
|
+
origin: debug-detection
|
|
232
|
+
citation: "WP-113: print() in non-CLI code pollutes logs"
|
|
233
|
+
languages: [python]
|
|
234
|
+
severity: WARNING
|
|
235
|
+
pattern: print(...)
|
|
236
|
+
paths:
|
|
237
|
+
exclude:
|
|
238
|
+
- "cli/"
|
|
239
|
+
- "scripts/"
|
|
240
|
+
- "**/cli.py"
|
|
241
|
+
- "**/main.py"
|
|
242
|
+
- "**/conftest.py"
|
|
243
|
+
- "tests/"
|
|
244
|
+
message: "print() in non-CLI code — use logging or remove."
|
|
245
|
+
|
|
246
|
+
- id: non-atomic-write-output
|
|
247
|
+
metadata:
|
|
248
|
+
origin: pipeline
|
|
249
|
+
citation: "Partial writes corrupted downstream readers on crash"
|
|
250
|
+
languages: [python]
|
|
251
|
+
severity: WARNING
|
|
252
|
+
paths:
|
|
253
|
+
include:
|
|
254
|
+
- "pipeline/"
|
|
255
|
+
- "output/"
|
|
256
|
+
patterns:
|
|
257
|
+
- pattern: $PATH.write_text(...)
|
|
258
|
+
message: >-
|
|
259
|
+
Direct file write is not atomic — crash produces corrupt output.
|
|
260
|
+
Fix: import tempfile, os; fd, tmp = tempfile.mkstemp(dir=path.parent);
|
|
261
|
+
os.write(fd, content.encode()); os.close(fd); os.replace(tmp, path)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
rules:
|
|
2
|
+
- id: empty-catch-block
|
|
3
|
+
pattern-either:
|
|
4
|
+
- pattern: |
|
|
5
|
+
catch ($ERR) { }
|
|
6
|
+
- pattern: |
|
|
7
|
+
catch { }
|
|
8
|
+
message: "Empty catch block swallows errors silently. Log or handle the error."
|
|
9
|
+
languages: [typescript, javascript]
|
|
10
|
+
severity: ERROR
|
|
11
|
+
metadata:
|
|
12
|
+
origin: fettle-v0.4.0
|
|
13
|
+
citation: "Common AI-generated pattern: try/catch without error handling"
|
|
14
|
+
fix: "Add error logging: catch (e) { console.error('Context:', e); throw e; }"
|
|
15
|
+
|
|
16
|
+
- id: unawaited-promise
|
|
17
|
+
patterns:
|
|
18
|
+
- pattern-either:
|
|
19
|
+
- pattern: fetch(...)
|
|
20
|
+
- pattern: axios(...)
|
|
21
|
+
- pattern: axios.$METHOD(...)
|
|
22
|
+
- pattern-not-inside: await $X
|
|
23
|
+
- pattern-not-inside: return $X
|
|
24
|
+
- pattern-not-inside: const $X = $Y
|
|
25
|
+
- pattern-not-inside: let $X = $Y
|
|
26
|
+
- pattern-not-inside: var $X = $Y
|
|
27
|
+
- pattern-not-inside: $X.then(...)
|
|
28
|
+
- pattern-not-inside: $X.catch(...)
|
|
29
|
+
message: "Unawaited promise from fetch/axios. Add await or handle the returned promise."
|
|
30
|
+
languages: [typescript, javascript]
|
|
31
|
+
severity: WARNING
|
|
32
|
+
metadata:
|
|
33
|
+
origin: fettle-v0.4.0
|
|
34
|
+
citation: "Fire-and-forget async calls lose errors and create race conditions"
|
|
35
|
+
fix: "Add await: const result = await fetch(url)"
|
|
36
|
+
note: >-
|
|
37
|
+
Scoped to known promise-returning APIs. Semgrep OSS has no type
|
|
38
|
+
inference, so matching arbitrary $FN(...) calls flagged every
|
|
39
|
+
statement-level call (~9k findings on a 23-file app).
|
|
40
|
+
|
|
41
|
+
- id: fetch-without-timeout
|
|
42
|
+
patterns:
|
|
43
|
+
- pattern-either:
|
|
44
|
+
- pattern: |
|
|
45
|
+
fetch($URL)
|
|
46
|
+
- pattern: |
|
|
47
|
+
fetch($URL, { ... })
|
|
48
|
+
- pattern-not: |
|
|
49
|
+
fetch($URL, { ..., signal: $SIGNAL, ... })
|
|
50
|
+
message: "fetch() without AbortSignal timeout. Network calls can hang indefinitely."
|
|
51
|
+
languages: [typescript, javascript]
|
|
52
|
+
severity: ERROR
|
|
53
|
+
metadata:
|
|
54
|
+
origin: fettle-v0.4.0
|
|
55
|
+
citation: "AI-generated fetch calls rarely include timeouts — causes hung processes"
|
|
56
|
+
fix: "Add signal: fetch(url, { signal: AbortSignal.timeout(10000) })"
|
|
57
|
+
|
|
58
|
+
- id: string-built-sql-ts
|
|
59
|
+
# pattern-regex (not template-literal patterns): semgrep's ellipsis does
|
|
60
|
+
# not match inside template literals, so `SELECT ... ${$VAR} ...` never
|
|
61
|
+
# fired — caught by the WP-116 integrity suite. The regex also catches
|
|
62
|
+
# SQL templates assigned to variables, not just call arguments.
|
|
63
|
+
pattern-regex: "(?i)`\\s*(SELECT|INSERT|UPDATE|DELETE)[^`]*\\$\\{[^`]*`"
|
|
64
|
+
message: "SQL query built with template literal interpolation — use parameterized queries."
|
|
65
|
+
languages: [typescript, javascript]
|
|
66
|
+
severity: ERROR
|
|
67
|
+
metadata:
|
|
68
|
+
origin: fettle-v0.4.0
|
|
69
|
+
citation: "SQL injection via template literals in AI-generated database code"
|
|
70
|
+
fix: "Use parameterized queries: db.query('SELECT * FROM t WHERE id = $1', [id])"
|
|
71
|
+
|
|
72
|
+
- id: debug-console-log
|
|
73
|
+
metadata:
|
|
74
|
+
origin: debug-detection
|
|
75
|
+
citation: "WP-113: Leftover console.log pollutes production logs"
|
|
76
|
+
languages: [typescript, javascript]
|
|
77
|
+
severity: WARNING
|
|
78
|
+
pattern: console.log(...)
|
|
79
|
+
paths:
|
|
80
|
+
exclude:
|
|
81
|
+
- "scripts/"
|
|
82
|
+
- "**/cli.*"
|
|
83
|
+
- "**/cli/"
|
|
84
|
+
message: "Leftover console.log — remove or replace with structured logger."
|
|
85
|
+
|
|
86
|
+
- id: debug-debugger-statement
|
|
87
|
+
metadata:
|
|
88
|
+
origin: debug-detection
|
|
89
|
+
citation: "WP-113: debugger statement halts execution in browser"
|
|
90
|
+
languages: [typescript, javascript]
|
|
91
|
+
severity: ERROR
|
|
92
|
+
pattern: debugger
|
|
93
|
+
message: "Leftover debugger statement — remove before shipping."
|
|
94
|
+
|
|
95
|
+
- id: regex-llm-output-ts
|
|
96
|
+
pattern-either:
|
|
97
|
+
- pattern: |
|
|
98
|
+
$X.match($REGEX)
|
|
99
|
+
- pattern: |
|
|
100
|
+
$REGEX.exec($X)
|
|
101
|
+
paths:
|
|
102
|
+
include:
|
|
103
|
+
- "agents/"
|
|
104
|
+
- "pipeline/"
|
|
105
|
+
- "llm/"
|
|
106
|
+
message: "Regex parsing of what may be LLM output. Use structured output (JSON, tool_use) instead."
|
|
107
|
+
languages: [typescript, javascript]
|
|
108
|
+
severity: WARNING
|
|
109
|
+
metadata:
|
|
110
|
+
origin: fettle-v0.4.0
|
|
111
|
+
citation: "LLM output is non-deterministic; regex parsing breaks on format drift"
|
|
112
|
+
fix: "Use structured output: JSON.parse() on tool_use response, or response_format: json"
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Fettle GitHub Action execution and SARIF output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> int:
|
|
13
|
+
mode = os.environ.get("INPUT_MODE", "advisory").lower()
|
|
14
|
+
if mode not in {"advisory", "enforce"}:
|
|
15
|
+
print(f"::error::Invalid mode '{mode}'; expected advisory or enforce", file=sys.stderr)
|
|
16
|
+
return 2
|
|
17
|
+
sarif_enabled = os.environ.get("INPUT_SARIF", "true").lower() == "true"
|
|
18
|
+
config_path = os.environ.get("INPUT_CONFIG", "")
|
|
19
|
+
paths = shlex.split(os.environ.get("INPUT_PATHS", "."))
|
|
20
|
+
github_output = os.environ.get("GITHUB_OUTPUT", "")
|
|
21
|
+
runner_temp = os.environ.get("RUNNER_TEMP", "/tmp")
|
|
22
|
+
|
|
23
|
+
cmd = [sys.executable, "-m", "fettle", "check", "--json", "--all"]
|
|
24
|
+
if config_path:
|
|
25
|
+
os.environ["FETTLE_CONFIG"] = os.path.abspath(config_path)
|
|
26
|
+
|
|
27
|
+
all_findings: list[dict] = []
|
|
28
|
+
scan_failed = False
|
|
29
|
+
for path in paths:
|
|
30
|
+
scan_path = os.path.abspath(path)
|
|
31
|
+
result = subprocess.run(
|
|
32
|
+
[*cmd, "--root", scan_path],
|
|
33
|
+
capture_output=True,
|
|
34
|
+
text=True,
|
|
35
|
+
)
|
|
36
|
+
parsed = False
|
|
37
|
+
if result.stdout.strip():
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(result.stdout)
|
|
40
|
+
findings = data.get("findings", []) if isinstance(data, dict) else data
|
|
41
|
+
all_findings.extend(findings)
|
|
42
|
+
parsed = True
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
print(f"::error::Could not parse fettle output for path '{path}'")
|
|
45
|
+
print(result.stdout[:500], file=sys.stderr)
|
|
46
|
+
if result.returncode not in (0, 1) or not parsed:
|
|
47
|
+
scan_failed = True
|
|
48
|
+
for line in result.stderr.strip().splitlines()[:10]:
|
|
49
|
+
print(f"::debug::{line}")
|
|
50
|
+
|
|
51
|
+
findings_count = len(all_findings)
|
|
52
|
+
has_errors = any(str(f.get("severity", "")).lower() == "error" for f in all_findings)
|
|
53
|
+
|
|
54
|
+
findings_path = os.path.join(runner_temp, "fettle-findings.json")
|
|
55
|
+
with open(findings_path, "w") as f:
|
|
56
|
+
json.dump(all_findings, f)
|
|
57
|
+
|
|
58
|
+
sarif_file = ""
|
|
59
|
+
if sarif_enabled:
|
|
60
|
+
sarif_file = os.path.join(runner_temp, "fettle.sarif")
|
|
61
|
+
with open(sarif_file, "w") as f:
|
|
62
|
+
json.dump(_findings_to_sarif(all_findings), f, indent=2)
|
|
63
|
+
|
|
64
|
+
exit_code = 2 if scan_failed else (1 if mode == "enforce" and has_errors else 0)
|
|
65
|
+
if github_output:
|
|
66
|
+
with open(github_output, "a") as f:
|
|
67
|
+
f.write(f"findings_count={findings_count}\n")
|
|
68
|
+
f.write(f"sarif_file={sarif_file}\n")
|
|
69
|
+
f.write(f"exit_code={exit_code}\n")
|
|
70
|
+
|
|
71
|
+
print(f"Fettle: {findings_count} finding(s), mode={mode}, exit_code={exit_code}")
|
|
72
|
+
if all_findings:
|
|
73
|
+
errors = [f for f in all_findings if str(f.get("severity", "")).lower() == "error"]
|
|
74
|
+
warnings = [f for f in all_findings if str(f.get("severity", "")).lower() != "error"]
|
|
75
|
+
if errors:
|
|
76
|
+
print(f" {len(errors)} error(s), {len(warnings)} warning(s)")
|
|
77
|
+
for finding in all_findings[:20]:
|
|
78
|
+
sev = finding.get("severity", "info").upper()
|
|
79
|
+
loc = f"{finding.get('file', '')}:{finding.get('line', '')}"
|
|
80
|
+
print(f" [{sev}] {loc} {finding.get('code', '')} - {finding.get('message', '')}")
|
|
81
|
+
if findings_count > 20:
|
|
82
|
+
print(f" ... and {findings_count - 20} more")
|
|
83
|
+
return exit_code
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _findings_to_sarif(findings: list[dict]) -> dict:
|
|
87
|
+
"""Convert findings to SARIF 2.1.0 format."""
|
|
88
|
+
results = []
|
|
89
|
+
rules: dict[str, dict] = {}
|
|
90
|
+
for finding in findings:
|
|
91
|
+
rule_id = finding.get("code", finding.get("rule", "unknown"))
|
|
92
|
+
severity = str(finding.get("severity", "warning")).lower()
|
|
93
|
+
file_path = finding.get("file", finding.get("path", ""))
|
|
94
|
+
line = finding.get("line", 1)
|
|
95
|
+
message = finding.get("message", "")
|
|
96
|
+
sarif_level = {"error": "error", "warning": "warning", "info": "note"}.get(severity, "warning")
|
|
97
|
+
rules.setdefault(rule_id, {
|
|
98
|
+
"id": rule_id,
|
|
99
|
+
"shortDescription": {"text": message[:100]},
|
|
100
|
+
"defaultConfiguration": {"level": sarif_level},
|
|
101
|
+
})
|
|
102
|
+
results.append({
|
|
103
|
+
"ruleId": rule_id,
|
|
104
|
+
"level": sarif_level,
|
|
105
|
+
"message": {"text": message},
|
|
106
|
+
"locations": [{
|
|
107
|
+
"physicalLocation": {
|
|
108
|
+
"artifactLocation": {"uri": file_path},
|
|
109
|
+
"region": {"startLine": line},
|
|
110
|
+
},
|
|
111
|
+
}] if file_path else [],
|
|
112
|
+
})
|
|
113
|
+
return {
|
|
114
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
115
|
+
"version": "2.1.0",
|
|
116
|
+
"runs": [{
|
|
117
|
+
"tool": {
|
|
118
|
+
"driver": {
|
|
119
|
+
"name": "fettle",
|
|
120
|
+
"informationUri": "https://github.com/MilindGaharwar/fettle",
|
|
121
|
+
"rules": list(rules.values()),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
"results": results,
|
|
125
|
+
}],
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
sys.exit(main())
|