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.
Files changed (112) hide show
  1. fettle/__init__.py +3 -0
  2. fettle/__main__.py +7 -0
  3. fettle/_resources.py +45 -0
  4. fettle/_rules/.ruff.toml +10 -0
  5. fettle/_rules/go-antipatterns.yml +68 -0
  6. fettle/_rules/llm-antipatterns.yml +261 -0
  7. fettle/_rules/ts-antipatterns.yml +112 -0
  8. fettle/action_entrypoint.py +130 -0
  9. fettle/adapters/__init__.py +74 -0
  10. fettle/adapters/go_adapter.py +136 -0
  11. fettle/adapters/python_adapter.py +204 -0
  12. fettle/adapters/rust_adapter.py +122 -0
  13. fettle/adapters/typescript_adapter.py +182 -0
  14. fettle/advisory.py +136 -0
  15. fettle/ai_summaries.py +38 -0
  16. fettle/artifact_gate.py +174 -0
  17. fettle/autofix.py +116 -0
  18. fettle/baseline.py +113 -0
  19. fettle/bash_audit.py +86 -0
  20. fettle/bench.py +117 -0
  21. fettle/blackduck_adapter.py +133 -0
  22. fettle/boundary_rules.py +105 -0
  23. fettle/boundary_scan.py +207 -0
  24. fettle/cache.py +105 -0
  25. fettle/changeset.py +141 -0
  26. fettle/ci.py +212 -0
  27. fettle/ci_diagnose.py +137 -0
  28. fettle/ci_ingest.py +105 -0
  29. fettle/cli.py +435 -0
  30. fettle/commit_message.py +184 -0
  31. fettle/complexity_check.py +177 -0
  32. fettle/config.py +316 -0
  33. fettle/config_protect.py +194 -0
  34. fettle/config_v050.py +139 -0
  35. fettle/coverage_gate.py +228 -0
  36. fettle/cross_review.py +106 -0
  37. fettle/debt_report.py +179 -0
  38. fettle/dep_check.py +251 -0
  39. fettle/deploy_gate.py +164 -0
  40. fettle/destructive_guard.py +210 -0
  41. fettle/discipline_link.py +114 -0
  42. fettle/dispatcher.py +171 -0
  43. fettle/dispatcher_aggregate.py +107 -0
  44. fettle/dispatcher_registry.py +257 -0
  45. fettle/dispatcher_types.py +128 -0
  46. fettle/doctor.py +98 -0
  47. fettle/entry_points.py +109 -0
  48. fettle/environment.py +141 -0
  49. fettle/evals_runner.py +203 -0
  50. fettle/event.py +105 -0
  51. fettle/explain.py +84 -0
  52. fettle/finding.py +244 -0
  53. fettle/fp_stamp.py +111 -0
  54. fettle/health_telemetry.py +267 -0
  55. fettle/import_graph.py +260 -0
  56. fettle/install.py +156 -0
  57. fettle/integration_base.py +63 -0
  58. fettle/lean_debt.py +98 -0
  59. fettle/lean_sniffers.py +647 -0
  60. fettle/learn.py +220 -0
  61. fettle/loop_detect.py +232 -0
  62. fettle/lsp_server.py +382 -0
  63. fettle/mcp_trust_gate.py +333 -0
  64. fettle/migration_safety.py +44 -0
  65. fettle/mutation_test.py +217 -0
  66. fettle/pact_adapter.py +115 -0
  67. fettle/paths.py +109 -0
  68. fettle/plan_validator.py +333 -0
  69. fettle/policy_layers.py +346 -0
  70. fettle/post_bash_doc_check.py +185 -0
  71. fettle/post_edit.py +455 -0
  72. fettle/post_edit_go.py +120 -0
  73. fettle/post_edit_ts.py +185 -0
  74. fettle/pr_review.py +148 -0
  75. fettle/profile.py +260 -0
  76. fettle/project_rules.py +85 -0
  77. fettle/provenance_gate.py +141 -0
  78. fettle/py.typed +0 -0
  79. fettle/quality_gate.py +591 -0
  80. fettle/quality_scan.py +374 -0
  81. fettle/ratchet.py +377 -0
  82. fettle/release_gate.py +103 -0
  83. fettle/report.py +161 -0
  84. fettle/result.py +136 -0
  85. fettle/review.py +130 -0
  86. fettle/sarif.py +101 -0
  87. fettle/schema_drift.py +45 -0
  88. fettle/scope_creep.py +186 -0
  89. fettle/secret_scan.py +123 -0
  90. fettle/security_review.py +195 -0
  91. fettle/semgrep_util.py +75 -0
  92. fettle/sonar_adapter.py +113 -0
  93. fettle/spec_audit.py +75 -0
  94. fettle/stop_quality_gate.py +285 -0
  95. fettle/suppressions_v3.py +343 -0
  96. fettle/tdd_gate.py +192 -0
  97. fettle/test_discovery.py +91 -0
  98. fettle/test_runner_opts.py +61 -0
  99. fettle/threat_model.py +168 -0
  100. fettle/tier_routing.py +87 -0
  101. fettle/tool_runner.py +101 -0
  102. fettle/trace.py +81 -0
  103. fettle/trace_requirements.py +207 -0
  104. fettle/ux_spec_gate.py +90 -0
  105. fettle/worklog.py +165 -0
  106. fettle/workspace.py +234 -0
  107. finefettle-1.0.2.dist-info/METADATA +387 -0
  108. finefettle-1.0.2.dist-info/RECORD +112 -0
  109. finefettle-1.0.2.dist-info/WHEEL +5 -0
  110. finefettle-1.0.2.dist-info/entry_points.txt +2 -0
  111. finefettle-1.0.2.dist-info/licenses/LICENSE +21 -0
  112. finefettle-1.0.2.dist-info/top_level.txt +1 -0
fettle/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Fettle — quality enforcement for AI-assisted development."""
2
+
3
+ __version__ = "1.0.2"
fettle/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Run the Fettle CLI with ``python -m fettle``."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ main()
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
@@ -0,0 +1,10 @@
1
+ line-length = 120
2
+ target-version = "py312"
3
+
4
+ [lint]
5
+ select = ["E", "F", "BLE", "S110", "S608", "S701", "B", "SIM", "UP"]
6
+ ignore = ["E501"]
7
+
8
+ [lint.per-file-ignores]
9
+ "tests/*" = ["S", "BLE"]
10
+ "*_generated.py" = ["ALL"]
@@ -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())