falsegreen-robot 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.9", "3.11", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - name: Install
20
+ run: |
21
+ python -m pip install --upgrade pip
22
+ pip install -e ".[dev]"
23
+ - name: Lint
24
+ run: ruff check src tests
25
+ - name: Test
26
+ run: pytest -q
27
+ - name: Self-scan (must not error)
28
+ run: python -m falsegreen_robot src tests
@@ -0,0 +1,47 @@
1
+ name: release
2
+
3
+ # Builds the package and publishes it to PyPI via Trusted Publishing (OIDC).
4
+ # No API token: the publish job exchanges a short-lived OIDC identity with PyPI.
5
+ # Fires on a published GitHub release, or manually for an existing tag.
6
+ #
7
+ # One-time setup (see RELEASE notes): on PyPI add a Trusted Publisher for project
8
+ # `falsegreen-robot` (owner vinicq, repo falsegreen-robot, workflow release.yml,
9
+ # environment pypi); create a GitHub environment named `pypi`.
10
+
11
+ on:
12
+ release:
13
+ types: [published]
14
+ workflow_dispatch:
15
+
16
+ permissions:
17
+ contents: read
18
+
19
+ jobs:
20
+ build:
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ - uses: actions/setup-python@v5
25
+ with:
26
+ python-version: "3.13"
27
+ - name: Build sdist and wheel
28
+ run: |
29
+ python -m pip install --upgrade build
30
+ python -m build
31
+ - uses: actions/upload-artifact@v4
32
+ with:
33
+ name: dist
34
+ path: dist/
35
+
36
+ publish:
37
+ needs: build
38
+ runs-on: ubuntu-latest
39
+ environment: pypi
40
+ permissions:
41
+ id-token: write # OIDC trusted publishing
42
+ steps:
43
+ - uses: actions/download-artifact@v4
44
+ with:
45
+ name: dist
46
+ path: dist/
47
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .mypy_cache/
6
+ *.egg-info/
7
+ build/
8
+ dist/
9
+ .venv/
10
+ venv/
11
+ .env
12
+ output.xml
13
+ log.html
14
+ report.html
15
+
16
+ # pointer to private audit hub - never commit
17
+ CLAUDE.local.md
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-06-22
10
+
11
+ ### Added
12
+
13
+ - `.resource` files and `*** Keywords ***` definitions are now scanned (in both `.robot`
14
+ and `.resource`). New code R2: a user keyword named like a verifier (Verify/Assert/Should)
15
+ whose body contains no verification — a hollow oracle, the root cause of missed C2b.
16
+ Call-level smells (C5/C7/C16) are also detected inside user keywords.
17
+ - Three groups (`false-positive` / `diagnostic` / `coupling`), like the sibling scanners.
18
+ Opt-in maintainability group (default off, `--diagnostics`): D2 (control flow at the
19
+ test/task level), M2 (too many steps).
20
+ - RPA support: scans `*** Tasks ***` in addition to `*** Test Cases ***`.
21
+ - Initial release. Deterministic static scanner for false-positive Robot Framework
22
+ tests, built on the official `robot.api.get_model` parser (no execution).
23
+ - Detection codes: C2 (empty test), C2b (no verification keyword), C3 (swallowed
24
+ `Run Keyword And Ignore Error`/`Return Status`), C5 (always-true), C7 (self-compare),
25
+ C16 (`Sleep` as synchronization), C21 (conditional-only verification), C32 (skipped), R1 (Pass Execution forced green). C3 also covers native TRY/EXCEPT swallow. Codes share ids with the sibling
26
+ scanners where the concept matches.
27
+ - Verification-keyword recognition across libraries: the `Should` convention plus
28
+ SeleniumLibrary/AppiumLibrary, Browser's assertion engine (`Get ... == expected`),
29
+ RESTinstance schema keywords, DatabaseLibrary, RequestsLibrary, and custom
30
+ `Verify*`/`Assert*`/`Validate*`/`Check *` keywords.
31
+ - CLI: paths, `--json`, `--disable`, `--version`. Exit codes 0/10/20.
32
+
33
+ [Unreleased]: https://github.com/vinicq/falsegreen-robot/compare/v0.1.0...HEAD
34
+ [0.1.0]: https://github.com/vinicq/falsegreen-robot/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vinicius Queiroz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: falsegreen-robot
3
+ Version: 0.1.0
4
+ Summary: Find Robot Framework tests that pass green without protecting anything: tests with no verification keyword, swallowed failures, always-true checks. Deterministic static scan, sibling of falsegreen (Python) and falsegreen-js (JS/TS).
5
+ Project-URL: Homepage, https://github.com/vinicq/falsegreen-robot
6
+ Project-URL: Issues, https://github.com/vinicq/falsegreen-robot/issues
7
+ Author: Vinicius Queiroz
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: code-quality,false-positive,robot-framework,robotframework,static-analysis,test-smells,testing
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: Robot Framework
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.8
18
+ Requires-Dist: robotframework>=4.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7; extra == 'dev'
21
+ Requires-Dist: ruff>=0.5; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # falsegreen-robot
25
+
26
+ **One problem, one tool: the false positive.** falsegreen-robot finds Robot Framework
27
+ tests that pass green without protecting anything - tests that let broken behavior
28
+ through because no keyword verifies anything, the failure is swallowed, the check is
29
+ always true, or the test is skipped.
30
+
31
+ Deterministic static scan over the official Robot Framework parser
32
+ (`robot.api.get_model`) - no execution. Sibling of
33
+ [falsegreen](https://github.com/vinicq/falsegreen) (Python/pytest) and
34
+ [falsegreen-js](https://github.com/vinicq/falsegreen-js) (JS/TS). The semantic,
35
+ intent-based pass lives in [falsegreen-skill](https://github.com/vinicq/falsegreen-skill).
36
+
37
+ ## Why
38
+
39
+ A green Robot suite is not proof of correctness. A test case can run keywords and never
40
+ call a verification keyword; a `Run Keyword And Ignore Error` can absorb the failure; a
41
+ `Should Be True ${TRUE}` can never fail. This tool flags the patterns a parser can
42
+ prove, before they reach review.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install falsegreen-robot
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ ```bash
53
+ falsegreen-robot # scan cwd
54
+ falsegreen-robot tests/ # scan a path
55
+ falsegreen-robot --json # machine-readable output
56
+ falsegreen-robot --disable C16 # turn off specific codes
57
+ ```
58
+
59
+ Exit code: `0` clean, `10` low-confidence only, `20` high-confidence present. Wire exit
60
+ `20` into CI to block the merge.
61
+
62
+ ## What it detects
63
+
64
+ The oracle in Robot is the **verification keyword**. The scanner recognizes them across
65
+ libraries (the `Should` convention plus library-specific forms: SeleniumLibrary
66
+ `Element Should Be Visible`, Browser's assertion engine `Get Text sel == expected`,
67
+ RESTinstance schema keywords, DatabaseLibrary `Row Count Should Be Equal`, custom
68
+ `Verify*`/`Assert*` keywords). A test with none of them verifies nothing.
69
+
70
+ | Code | Confidence | What it flags |
71
+ |---|---|---|
72
+ | C2 | high | empty test case (no keywords run) |
73
+ | C2b | low | runs keywords but no verification keyword (no oracle) |
74
+ | C3 | high | `Run Keyword And Ignore Error`/`Return Status` swallows the failure, status never asserted |
75
+ | C5 | high | always-true (`Should Be True ${TRUE}`, `Should Be Equal` with equal literals) |
76
+ | C7 | high | self-compare (`Should Be Equal ${x} ${x}`) |
77
+ | C16 | low | `Sleep` used as synchronization (timing dependence) |
78
+ | C21 | low | verification only runs conditionally (inside `IF` / `Run Keyword If`) — it may never execute |
79
+ | C32 | low | skipped test (`robot:skip` / `Skip`) |
80
+ | R1 | high | `Pass Execution` forces the test green regardless of any check |
81
+ | R2 | low | user keyword named like a verifier (`Verify`/`Assert`/`Should`...) but its body verifies nothing — a hollow oracle |
82
+
83
+ Scans `*** Test Cases ***`, `*** Tasks ***` (RPA), and `*** Keywords ***` definitions in
84
+ both `.robot` and `.resource` files. R2 catches the root cause of a missed C2b: a test
85
+ calls `Verify Login` and looks protected, but that keyword never asserts anything.
86
+
87
+ ### Opt-in: maintainability group (default off)
88
+
89
+ Not false-green - the test still verifies - so off by default. Enable with `--diagnostics`.
90
+ Three groups, mirroring `falsegreen` and `falsegreen-js`: `false-positive` (C*/R*, on),
91
+ `diagnostic` (D*, opt-in), `coupling` (M*, opt-in).
92
+
93
+ | Code | Group | What it flags |
94
+ |---|---|---|
95
+ | D2 | diagnostic | control flow (`IF`/`FOR`/`WHILE`/`TRY`) at the test/task level (the guide advises against it) |
96
+ | M2 | coupling | test/task with too many steps (guide suggests max ~10) |
97
+
98
+ ```bash
99
+ falsegreen-robot --diagnostics # include D*/M* as warnings
100
+ ```
101
+
102
+ Codes share ids with the sibling scanners where the concept matches (C2/C2b/C3/C5/C7/C16/C21/C32).
103
+ A Browser `Get` keyword with no assertion operator is a plain getter, so a test whose only
104
+ step is `Get Text h1` surfaces as no-verification (C2b).
105
+
106
+ ## Scope and honesty
107
+
108
+ Static scan: it owns what the keyword structure proves. It does not run the suite, so it
109
+ cannot see runtime-only smells (Test Run War, order dependence across suites). Whether the
110
+ expected value contradicts the intended behavior is semantic and belongs to
111
+ `falsegreen-skill`. Precision over recall: `C2b` is low-confidence because a custom keyword
112
+ may assert internally without `Should` in its name.
113
+
114
+ ## License
115
+
116
+ MIT, Vinicius Queiroz.
@@ -0,0 +1,93 @@
1
+ # falsegreen-robot
2
+
3
+ **One problem, one tool: the false positive.** falsegreen-robot finds Robot Framework
4
+ tests that pass green without protecting anything - tests that let broken behavior
5
+ through because no keyword verifies anything, the failure is swallowed, the check is
6
+ always true, or the test is skipped.
7
+
8
+ Deterministic static scan over the official Robot Framework parser
9
+ (`robot.api.get_model`) - no execution. Sibling of
10
+ [falsegreen](https://github.com/vinicq/falsegreen) (Python/pytest) and
11
+ [falsegreen-js](https://github.com/vinicq/falsegreen-js) (JS/TS). The semantic,
12
+ intent-based pass lives in [falsegreen-skill](https://github.com/vinicq/falsegreen-skill).
13
+
14
+ ## Why
15
+
16
+ A green Robot suite is not proof of correctness. A test case can run keywords and never
17
+ call a verification keyword; a `Run Keyword And Ignore Error` can absorb the failure; a
18
+ `Should Be True ${TRUE}` can never fail. This tool flags the patterns a parser can
19
+ prove, before they reach review.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install falsegreen-robot
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```bash
30
+ falsegreen-robot # scan cwd
31
+ falsegreen-robot tests/ # scan a path
32
+ falsegreen-robot --json # machine-readable output
33
+ falsegreen-robot --disable C16 # turn off specific codes
34
+ ```
35
+
36
+ Exit code: `0` clean, `10` low-confidence only, `20` high-confidence present. Wire exit
37
+ `20` into CI to block the merge.
38
+
39
+ ## What it detects
40
+
41
+ The oracle in Robot is the **verification keyword**. The scanner recognizes them across
42
+ libraries (the `Should` convention plus library-specific forms: SeleniumLibrary
43
+ `Element Should Be Visible`, Browser's assertion engine `Get Text sel == expected`,
44
+ RESTinstance schema keywords, DatabaseLibrary `Row Count Should Be Equal`, custom
45
+ `Verify*`/`Assert*` keywords). A test with none of them verifies nothing.
46
+
47
+ | Code | Confidence | What it flags |
48
+ |---|---|---|
49
+ | C2 | high | empty test case (no keywords run) |
50
+ | C2b | low | runs keywords but no verification keyword (no oracle) |
51
+ | C3 | high | `Run Keyword And Ignore Error`/`Return Status` swallows the failure, status never asserted |
52
+ | C5 | high | always-true (`Should Be True ${TRUE}`, `Should Be Equal` with equal literals) |
53
+ | C7 | high | self-compare (`Should Be Equal ${x} ${x}`) |
54
+ | C16 | low | `Sleep` used as synchronization (timing dependence) |
55
+ | C21 | low | verification only runs conditionally (inside `IF` / `Run Keyword If`) — it may never execute |
56
+ | C32 | low | skipped test (`robot:skip` / `Skip`) |
57
+ | R1 | high | `Pass Execution` forces the test green regardless of any check |
58
+ | R2 | low | user keyword named like a verifier (`Verify`/`Assert`/`Should`...) but its body verifies nothing — a hollow oracle |
59
+
60
+ Scans `*** Test Cases ***`, `*** Tasks ***` (RPA), and `*** Keywords ***` definitions in
61
+ both `.robot` and `.resource` files. R2 catches the root cause of a missed C2b: a test
62
+ calls `Verify Login` and looks protected, but that keyword never asserts anything.
63
+
64
+ ### Opt-in: maintainability group (default off)
65
+
66
+ Not false-green - the test still verifies - so off by default. Enable with `--diagnostics`.
67
+ Three groups, mirroring `falsegreen` and `falsegreen-js`: `false-positive` (C*/R*, on),
68
+ `diagnostic` (D*, opt-in), `coupling` (M*, opt-in).
69
+
70
+ | Code | Group | What it flags |
71
+ |---|---|---|
72
+ | D2 | diagnostic | control flow (`IF`/`FOR`/`WHILE`/`TRY`) at the test/task level (the guide advises against it) |
73
+ | M2 | coupling | test/task with too many steps (guide suggests max ~10) |
74
+
75
+ ```bash
76
+ falsegreen-robot --diagnostics # include D*/M* as warnings
77
+ ```
78
+
79
+ Codes share ids with the sibling scanners where the concept matches (C2/C2b/C3/C5/C7/C16/C21/C32).
80
+ A Browser `Get` keyword with no assertion operator is a plain getter, so a test whose only
81
+ step is `Get Text h1` surfaces as no-verification (C2b).
82
+
83
+ ## Scope and honesty
84
+
85
+ Static scan: it owns what the keyword structure proves. It does not run the suite, so it
86
+ cannot see runtime-only smells (Test Run War, order dependence across suites). Whether the
87
+ expected value contradicts the intended behavior is semantic and belongs to
88
+ `falsegreen-skill`. Precision over recall: `C2b` is low-confidence because a custom keyword
89
+ may assert internally without `Should` in its name.
90
+
91
+ ## License
92
+
93
+ MIT, Vinicius Queiroz.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "falsegreen-robot"
7
+ version = "0.1.0"
8
+ description = "Find Robot Framework tests that pass green without protecting anything: tests with no verification keyword, swallowed failures, always-true checks. Deterministic static scan, sibling of falsegreen (Python) and falsegreen-js (JS/TS)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Vinicius Queiroz" }]
14
+ keywords = ["robot-framework", "robotframework", "testing", "test-smells", "false-positive", "static-analysis", "code-quality"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Framework :: Robot Framework",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Quality Assurance",
21
+ "Topic :: Software Development :: Testing",
22
+ ]
23
+ dependencies = ["robotframework>=4.0"]
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest>=7", "ruff>=0.5"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/vinicq/falsegreen-robot"
30
+ Issues = "https://github.com/vinicq/falsegreen-robot/issues"
31
+
32
+ [project.scripts]
33
+ falsegreen-robot = "falsegreen_robot.scanner:main"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/falsegreen_robot"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ from .scanner import main, scan, analyze_file, CASES, __version__
2
+
3
+ __all__ = ["main", "scan", "analyze_file", "CASES", "__version__"]
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .scanner import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,408 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ falsegreen-robot: deterministic false-positive scanner for Robot Framework tests.
5
+
6
+ Parses .robot files with the official Robot Framework parser (robot.api.get_model)
7
+ - no execution - and flags test cases that pass green without protecting anything:
8
+ a test with no verification keyword, a swallowed Run Keyword And Ignore Error, an
9
+ always-true Should Be True ${TRUE}, a self-compare, Sleep used as a wait, a skipped
10
+ test. Sibling of falsegreen (Python/pytest) and falsegreen-js (JS/TS).
11
+
12
+ Output: readable text (default) or JSON (--json).
13
+ Exit: 0 clean, 10 low-confidence only, 20 high-confidence present.
14
+ """
15
+ import argparse
16
+ import json
17
+ import os
18
+ import sys
19
+
20
+ __version__ = "0.1.0"
21
+ TOOL_URI = "https://github.com/vinicq/falsegreen-robot"
22
+
23
+ # --- case catalog. code -> (title, confidence, judgment J1-J6) -------------
24
+ JUDGMENTS = {
25
+ "J1": "does the verification run?",
26
+ "J2": "is the oracle independent of the code?",
27
+ "J4": "does it check enough, and the right thing?",
28
+ "J5": "is it coupled / hard to maintain?",
29
+ }
30
+ CASES = {
31
+ "C2": ("empty test case (no keywords run)", "high", "J1"),
32
+ "C2b": ("runs keywords but no verification keyword (no oracle)", "low", "J1"),
33
+ "C3": ("Run Keyword And Ignore Error/Return Status swallows the failure and the status is never asserted", "high", "J1"),
34
+ "C5": ("always-true check (Should Be True ${TRUE} / Should Be Equal with equal literals)", "high", "J2"),
35
+ "C7": ("self-compare (Should Be Equal ${x} ${x})", "high", "J2"),
36
+ "C16": ("Sleep used as synchronization (result depends on timing)", "low", "J1"),
37
+ "C21": ("verification only runs conditionally (inside IF / Run Keyword If) — it may never execute", "low", "J1"),
38
+ "C32": ("skipped test (robot:skip / Skip) never runs", "low", "J1"),
39
+ "R1": ("Pass Execution forces the test to pass regardless of any check (forced green)", "high", "J1"),
40
+ "R2": ("user keyword named like a verifier (Verify/Assert/Should...) but its body contains no verification — a hollow oracle", "low", "J1"),
41
+ # --- diagnostic group (maintainability; default off, opt-in via --diagnostics) ---
42
+ "D2": ("control flow (IF/FOR/WHILE/TRY) at the test/task level — the guide advises against it", "off", "J4"),
43
+ # --- coupling group (structure; default off, opt-in) ----------------------
44
+ "M2": ("test/task has too many steps (the guide suggests max ~10)", "off", "J5"),
45
+ }
46
+
47
+ # Default thresholds for the opt-in groups (overridable later via config).
48
+ DIAGNOSTIC_THRESHOLDS = {"long_test_steps": 10}
49
+
50
+
51
+ def group_of(code):
52
+ """false-positive (C*/R*) / diagnostic (D*) / coupling (M*) — mirrors the siblings."""
53
+ if code.startswith("D"):
54
+ return "diagnostic"
55
+ if code.startswith("M"):
56
+ return "coupling"
57
+ return "false-positive"
58
+
59
+ # --- verification vocabulary (the oracle), across Robot libraries ----------
60
+ # Dominant convention: the word "Should". Plus library-specific forms.
61
+ REST_SCHEMA = {"Integer", "Number", "String", "Boolean", "Object", "Array", "Null", "Missing"}
62
+ BROWSER_OPS = {"==", "!=", "contains", "not contains", "validate", "matches",
63
+ ">", "<", ">=", "<=", "*=", "^=", "$=", "then"}
64
+ VERIFY_PREFIXES = ("verify", "assert", "validate", "check ")
65
+ SWALLOW_KEYWORDS = {"run keyword and ignore error", "run keyword and return status"}
66
+
67
+
68
+ def _norm(name):
69
+ return (name or "").strip().lower()
70
+
71
+
72
+ def is_verification(keyword, args):
73
+ """True if this keyword call verifies an expected result (is an oracle)."""
74
+ if keyword is None:
75
+ return False
76
+ n = _norm(keyword)
77
+ if "should" in n:
78
+ return True # BuiltIn/Collections/String/Selenium/...
79
+ if keyword in REST_SCHEMA:
80
+ return True # RESTinstance schema assertions
81
+ if n.startswith(VERIFY_PREFIXES):
82
+ return True # custom Verify*/Assert*/Validate*/Check *
83
+ if n.startswith("wait until") and any(w in n for w in ("contain", "visible", "present")):
84
+ return True # Selenium/Appium waits that fail on timeout
85
+ if n.startswith("get ") and any(a in BROWSER_OPS for a in (args or ())):
86
+ return True # Browser assertion engine: Get ... == expected
87
+ return False
88
+
89
+
90
+ def is_swallow(keyword):
91
+ return _norm(keyword) in SWALLOW_KEYWORDS
92
+
93
+
94
+ def _looks_constant_true(arg):
95
+ return _norm(arg) in ("${true}", "true", "1", "${1}")
96
+
97
+
98
+ # --- AST walk over the Robot model -----------------------------------------
99
+ def _keyword_calls(node):
100
+ """Yield every KeywordCall in a block, descending into IF/FOR/TRY/WHILE."""
101
+ for item in getattr(node, "body", None) or []:
102
+ cls = type(item).__name__
103
+ if cls == "KeywordCall":
104
+ yield item
105
+ if hasattr(item, "body"):
106
+ yield from _keyword_calls(item)
107
+
108
+
109
+ def _top_level_keyword_calls(testcase):
110
+ """KeywordCalls directly in the test body (not nested in IF/FOR/TRY/WHILE)."""
111
+ return [i for i in (getattr(testcase, "body", None) or [])
112
+ if type(i).__name__ == "KeywordCall"]
113
+
114
+
115
+ def _has_control_block(testcase):
116
+ return any(type(i).__name__ in ("If", "For", "While", "Try")
117
+ for i in (getattr(testcase, "body", None) or []))
118
+
119
+
120
+ def _rkif_verifies(call):
121
+ """A `Run Keyword If`/`Unless` whose arguments name a verification keyword -
122
+ that is a conditional verification (may not run)."""
123
+ if _norm(getattr(call, "keyword", None)) in ("run keyword if", "run keyword unless"):
124
+ return any("should" in _norm(a) for a in (getattr(call, "args", None) or ()))
125
+ return False
126
+
127
+
128
+ def _try_blocks(node):
129
+ """Yield native TRY blocks (RF 5+) anywhere in the test body."""
130
+ for item in getattr(node, "body", None) or []:
131
+ if type(item).__name__ == "Try" and _norm(getattr(item, "type", "")) == "try":
132
+ yield item
133
+ if hasattr(item, "body"):
134
+ yield from _try_blocks(item)
135
+
136
+
137
+ def _except_swallows(try_node):
138
+ """True if any EXCEPT branch of this TRY swallows the failure: its body has no
139
+ Fail, no verification keyword, and no re-raise - only logging / no-op / empty."""
140
+ branch = getattr(try_node, "next", None)
141
+ while branch is not None:
142
+ if _norm(getattr(branch, "type", "")) == "except":
143
+ harmless = True
144
+ for st in getattr(branch, "body", None) or []:
145
+ if type(st).__name__ != "KeywordCall":
146
+ continue
147
+ kw = _norm(st.keyword)
148
+ if kw in ("fail", "fatal error") or "should" in kw or kw.startswith(VERIFY_PREFIXES):
149
+ harmless = False
150
+ break
151
+ if harmless:
152
+ return True
153
+ branch = getattr(branch, "next", None)
154
+ return False
155
+
156
+
157
+ def _try_body_has_keyword(try_node):
158
+ return any(type(st).__name__ == "KeywordCall" for st in (getattr(try_node, "body", None) or []))
159
+
160
+
161
+ def _tags(testcase):
162
+ tags = []
163
+ for item in getattr(testcase, "body", None) or []:
164
+ if type(item).__name__ == "Tags":
165
+ tags += [str(v) for v in getattr(item, "values", []) or []]
166
+ return tags
167
+
168
+
169
+ class Finding:
170
+ __slots__ = ("file", "line", "test", "code", "detail")
171
+
172
+ def __init__(self, file, line, test, code, detail=""):
173
+ self.file = file
174
+ self.line = line
175
+ self.test = test
176
+ self.code = code
177
+ self.detail = detail
178
+
179
+ def dict(self):
180
+ title, conf, judg = CASES[self.code]
181
+ return {"file": self.file, "line": self.line, "test": self.test,
182
+ "code": self.code, "confidence": conf, "judgment": judg,
183
+ "title": title, "detail": self.detail}
184
+
185
+
186
+ def _name_implies_verification(name):
187
+ """A user-keyword name that promises to verify. 'Check' is excluded - it often
188
+ names a getter that returns a status for the caller to assert."""
189
+ n = _norm(name)
190
+ return "should" in n or n.startswith(("verify", "assert", "validate"))
191
+
192
+
193
+ def _call_level_smells(file, owner, calls, findings):
194
+ """Per-call false-green checks shared by test cases, tasks, and user keywords:
195
+ C5 (always-true), C7 (self-compare), C16 (Sleep). Returns whether any keyword
196
+ call verifies something."""
197
+ has_verification = False
198
+ for c in calls:
199
+ kw, args = c.keyword, list(getattr(c, "args", []) or [])
200
+ ln = getattr(c, "lineno", 0) or 0
201
+ if _norm(kw) == "should be true" and args and _looks_constant_true(args[0]):
202
+ findings.append(Finding(file, ln, owner, "C5", "Should Be True on a constant"))
203
+ has_verification = True
204
+ continue
205
+ if _norm(kw) == "should be equal" and len(args) >= 2:
206
+ if args[0] == args[1]:
207
+ code = "C7" if args[0].startswith("${") else "C5"
208
+ findings.append(Finding(file, ln, owner, code, "both sides are identical"))
209
+ has_verification = True
210
+ continue
211
+ if _norm(kw) == "sleep":
212
+ findings.append(Finding(file, ln, owner, "C16"))
213
+ if is_verification(kw, args) or _rkif_verifies(c):
214
+ has_verification = True
215
+ return has_verification
216
+
217
+
218
+ def analyze_keyword(file, kw, findings):
219
+ """Analyze a User Keyword definition (.robot Keywords section or .resource).
220
+ Flags call-level smells inside the body, and R2 when the keyword is named like a
221
+ verifier but verifies nothing (a hollow oracle used by tests)."""
222
+ name = getattr(kw, "name", "") or ""
223
+ line = getattr(kw, "lineno", 0) or 0
224
+ calls = list(_keyword_calls(kw))
225
+ has_verification = _call_level_smells(file, name, calls, findings)
226
+ if _name_implies_verification(name) and not has_verification:
227
+ findings.append(Finding(file, line, name, "R2"))
228
+
229
+
230
+ def analyze_testcase(file, tc, findings):
231
+ name = getattr(tc, "name", "") or ""
232
+ line = getattr(tc, "lineno", 0) or 0
233
+ calls = list(_keyword_calls(tc))
234
+ tags = [_norm(t) for t in _tags(tc)]
235
+
236
+ # C32: skipped
237
+ if any("robot:skip" in t for t in tags) or any(_norm(c.keyword) in ("skip",) for c in calls):
238
+ findings.append(Finding(file, line, name, "C32"))
239
+ return
240
+
241
+ # R1: Pass Execution at the top level forces the test green regardless of checks
242
+ if any(_norm(c.keyword) == "pass execution" for c in _top_level_keyword_calls(tc)):
243
+ findings.append(Finding(file, line, name, "R1"))
244
+ return
245
+
246
+ # C2: empty (no keyword calls at all)
247
+ if not calls:
248
+ findings.append(Finding(file, line, name, "C2"))
249
+ return
250
+
251
+ has_verification = _call_level_smells(file, name, calls, findings)
252
+
253
+ # diagnostic/coupling group (off by default; emitted always, filtered in scan)
254
+ if _has_control_block(tc):
255
+ findings.append(Finding(file, line, name, "D2"))
256
+ if len(calls) > DIAGNOSTIC_THRESHOLDS["long_test_steps"]:
257
+ findings.append(Finding(file, line, name, "M2", "%d steps" % len(calls)))
258
+
259
+ # C3: native TRY/EXCEPT whose EXCEPT swallows the failure
260
+ for tb in _try_blocks(tc):
261
+ if _try_body_has_keyword(tb) and _except_swallows(tb):
262
+ findings.append(Finding(file, getattr(tb, "lineno", line), name, "C3",
263
+ "TRY failure is swallowed by EXCEPT"))
264
+ return
265
+
266
+ # C3: swallow without an asserted status
267
+ if not has_verification and any(is_swallow(c.keyword) for c in calls):
268
+ findings.append(Finding(file, line, name, "C3"))
269
+ return
270
+
271
+ # C2b: keywords ran but nothing verified
272
+ if not has_verification:
273
+ findings.append(Finding(file, line, name, "C2b"))
274
+ return
275
+
276
+ # C21: a verification exists, but none runs unconditionally — the only
277
+ # verification lives inside an IF/FOR block or a Run Keyword If. The guide is
278
+ # explicit: no if/else/for at the test-case level.
279
+ top = _top_level_keyword_calls(tc)
280
+ has_unconditional = any(
281
+ is_verification(c.keyword, list(getattr(c, "args", []) or []))
282
+ for c in top
283
+ if _norm(c.keyword) not in ("run keyword if", "run keyword unless")
284
+ )
285
+ if not has_unconditional and (_has_control_block(tc) or any(_rkif_verifies(c) for c in calls)):
286
+ findings.append(Finding(file, line, name, "C21"))
287
+
288
+
289
+ def analyze_file(path):
290
+ findings = []
291
+ try:
292
+ from robot.api import get_model
293
+ from robot.parsing import ModelVisitor
294
+ except Exception as exc: # pragma: no cover
295
+ sys.stderr.write("falsegreen-robot: robotframework is required (%s)\n" % exc)
296
+ return findings
297
+ try:
298
+ model = get_model(path)
299
+ except Exception:
300
+ return findings
301
+ self_findings = findings
302
+
303
+ class _V(ModelVisitor):
304
+ def visit_TestCase(self, node):
305
+ analyze_testcase(path, node, self_findings)
306
+
307
+ def visit_Task(self, node): # RPA suites use *** Tasks ***, not *** Test Cases ***
308
+ analyze_testcase(path, node, self_findings)
309
+
310
+ def visit_Keyword(self, node): # user keyword defs in .robot Keywords + .resource
311
+ analyze_keyword(path, node, self_findings)
312
+
313
+ _V().visit(model)
314
+ return findings
315
+
316
+
317
+ # --- discovery + CLI -------------------------------------------------------
318
+ IGNORED_DIRS = {".git", ".tox", "venv", ".venv", "node_modules", "results", "output"}
319
+
320
+
321
+ def is_robot_file(path):
322
+ return path.endswith((".robot", ".resource"))
323
+
324
+
325
+ def discover(paths):
326
+ files = []
327
+ for root in paths:
328
+ if os.path.isfile(root):
329
+ if is_robot_file(root):
330
+ files.append(root)
331
+ continue
332
+ for dirpath, dirnames, filenames in os.walk(root):
333
+ dirnames[:] = [d for d in dirnames if d not in IGNORED_DIRS]
334
+ for f in filenames:
335
+ if is_robot_file(f):
336
+ files.append(os.path.join(dirpath, f))
337
+ return sorted(set(files))
338
+
339
+
340
+ def _eff_conf(code):
341
+ """Effective confidence: an off-by-default (D*/M*) code shows as 'low' when enabled."""
342
+ c = CASES[code][1]
343
+ return "low" if c == "off" else c
344
+
345
+
346
+ def scan(paths, disable=None, diagnostics=False):
347
+ disable = disable or set()
348
+ out = []
349
+ for f in discover(paths):
350
+ for finding in analyze_file(f):
351
+ conf = CASES[finding.code][1]
352
+ if finding.code in disable:
353
+ continue
354
+ if conf == "off" and not diagnostics:
355
+ continue
356
+ out.append(finding)
357
+ return out
358
+
359
+
360
+ def _render_text(findings):
361
+ if not findings:
362
+ return "falsegreen-robot: no false-positive patterns found."
363
+ lines, high, low = [], 0, 0
364
+ by_file = {}
365
+ for f in findings:
366
+ by_file.setdefault(f.file, []).append(f)
367
+ for file, fs in by_file.items():
368
+ lines.append("\n" + file)
369
+ for f in sorted(fs, key=lambda x: x.line):
370
+ title = CASES[f.code][0]
371
+ conf = _eff_conf(f.code)
372
+ tag = "HIGH" if conf == "high" else "low "
373
+ high += conf == "high"
374
+ low += conf == "low"
375
+ lines.append(" %s %-4s L%-4d %s %s" % (tag, f.code, f.line, f.test, title))
376
+ if f.detail:
377
+ lines.append(" " + f.detail)
378
+ lines.append("\n%d high, %d low. %s" % (high, low, TOOL_URI))
379
+ return "\n".join(lines)
380
+
381
+
382
+ def main(argv=None):
383
+ p = argparse.ArgumentParser(prog="falsegreen-robot",
384
+ description="Find false-positive Robot Framework tests (static).")
385
+ p.add_argument("paths", nargs="*", default=["."], help="files or directories (default: cwd)")
386
+ p.add_argument("--json", action="store_true", help="JSON output")
387
+ p.add_argument("--disable", default="", help="comma-separated codes to turn off")
388
+ p.add_argument("--diagnostics", action="store_true",
389
+ help="also report the opt-in maintainability group (D*/M*)")
390
+ p.add_argument("--version", action="version", version=__version__)
391
+ args = p.parse_args(argv)
392
+ disable = {c.strip() for c in args.disable.split(",") if c.strip()}
393
+ findings = scan(args.paths or ["."], disable=disable, diagnostics=args.diagnostics)
394
+ if args.json:
395
+ print(json.dumps({"tool": "falsegreen-robot", "version": __version__,
396
+ "judgments": JUDGMENTS,
397
+ "findings": [f.dict() for f in findings]}, indent=2))
398
+ else:
399
+ print(_render_text(findings))
400
+ if any(_eff_conf(f.code) == "high" for f in findings):
401
+ return 20
402
+ if findings:
403
+ return 10
404
+ return 0
405
+
406
+
407
+ if __name__ == "__main__":
408
+ sys.exit(main())
@@ -0,0 +1,282 @@
1
+ """Tests for falsegreen-robot. Each fixture is a tiny .robot file."""
2
+ from falsegreen_robot.scanner import analyze_file, scan, group_of
3
+
4
+
5
+ def codes(tmp_path, body, name="t.robot"):
6
+ f = tmp_path / name
7
+ f.write_text(body, encoding="utf-8")
8
+ return {x.code for x in analyze_file(str(f))}
9
+
10
+
11
+ def test_clean_test_has_no_findings(tmp_path):
12
+ body = """\
13
+ *** Test Cases ***
14
+ Adds Two Numbers
15
+ ${r}= Evaluate 2 + 2
16
+ Should Be Equal As Integers ${r} 4
17
+ """
18
+ assert codes(tmp_path, body) == set()
19
+
20
+
21
+ def test_c2_empty_test(tmp_path):
22
+ body = """\
23
+ *** Test Cases ***
24
+ Empty
25
+ [Documentation] nothing here
26
+ """
27
+ assert "C2" in codes(tmp_path, body)
28
+
29
+
30
+ def test_c2b_no_verification(tmp_path):
31
+ body = """\
32
+ *** Test Cases ***
33
+ No Oracle
34
+ Log hello
35
+ Open Browser http://x
36
+ """
37
+ assert "C2b" in codes(tmp_path, body)
38
+
39
+
40
+ def test_c3_swallowed_failure(tmp_path):
41
+ body = """\
42
+ *** Test Cases ***
43
+ Swallow
44
+ Run Keyword And Ignore Error Do Risky Thing
45
+ """
46
+ assert "C3" in codes(tmp_path, body)
47
+
48
+
49
+ def test_c5_always_true(tmp_path):
50
+ body = """\
51
+ *** Test Cases ***
52
+ Tautology
53
+ Should Be True ${TRUE}
54
+ """
55
+ assert "C5" in codes(tmp_path, body)
56
+
57
+
58
+ def test_c7_self_compare(tmp_path):
59
+ body = """\
60
+ *** Test Cases ***
61
+ Self
62
+ Should Be Equal ${value} ${value}
63
+ """
64
+ assert "C7" in codes(tmp_path, body)
65
+
66
+
67
+ def test_c16_sleep(tmp_path):
68
+ body = """\
69
+ *** Test Cases ***
70
+ Sleepy
71
+ Sleep 2s
72
+ Should Be Equal ${a} ${b}
73
+ """
74
+ assert "C16" in codes(tmp_path, body)
75
+
76
+
77
+ def test_c32_skip(tmp_path):
78
+ body = """\
79
+ *** Test Cases ***
80
+ Skipped
81
+ [Tags] robot:skip
82
+ Should Be Equal ${a} ${b}
83
+ """
84
+ assert "C32" in codes(tmp_path, body)
85
+
86
+
87
+ def test_c21_verification_only_inside_if(tmp_path):
88
+ body = """\
89
+ *** Test Cases ***
90
+ Conditional Check
91
+ Do Something
92
+ IF ${ready}
93
+ Should Be Equal ${a} ${b}
94
+ END
95
+ """
96
+ assert "C21" in codes(tmp_path, body)
97
+
98
+
99
+ def test_c21_run_keyword_if_verification(tmp_path):
100
+ body = """\
101
+ *** Test Cases ***
102
+ Guarded
103
+ Run Keyword If ${ready} Should Be Equal ${a} ${b}
104
+ """
105
+ assert "C21" in codes(tmp_path, body)
106
+
107
+
108
+ def test_no_c21_when_an_unconditional_verification_exists(tmp_path):
109
+ body = """\
110
+ *** Test Cases ***
111
+ Mixed
112
+ Should Be Equal ${a} ${b}
113
+ IF ${ready}
114
+ Should Contain ${x} y
115
+ END
116
+ """
117
+ assert "C21" not in codes(tmp_path, body)
118
+
119
+
120
+ def test_r1_pass_execution_forces_green(tmp_path):
121
+ body = """\
122
+ *** Test Cases ***
123
+ Forced
124
+ Pass Execution skip the real check
125
+ Should Be Equal ${a} ${b}
126
+ """
127
+ assert "R1" in codes(tmp_path, body)
128
+
129
+
130
+ def test_c3_native_try_except_swallows(tmp_path):
131
+ body = """\
132
+ *** Test Cases ***
133
+ Swallowed
134
+ TRY
135
+ Do Risky Thing
136
+ Should Be Equal ${a} ${b}
137
+ EXCEPT AS ${e}
138
+ Log ${e}
139
+ END
140
+ """
141
+ assert "C3" in codes(tmp_path, body)
142
+
143
+
144
+ def test_no_c3_when_except_reraises_with_fail(tmp_path):
145
+ body = """\
146
+ *** Test Cases ***
147
+ Proper
148
+ TRY
149
+ Do Risky Thing
150
+ EXCEPT AS ${e}
151
+ Fail unexpected: ${e}
152
+ END
153
+ Should Be Equal ${a} ${b}
154
+ """
155
+ assert "C3" not in codes(tmp_path, body)
156
+
157
+
158
+ def test_browser_get_without_operator_is_no_verification(tmp_path):
159
+ body = """\
160
+ *** Test Cases ***
161
+ Getter Only
162
+ Get Text h1
163
+ """
164
+ assert "C2b" in codes(tmp_path, body)
165
+
166
+
167
+ def test_browser_get_with_operator_is_clean(tmp_path):
168
+ body = """\
169
+ *** Test Cases ***
170
+ Browser Assert
171
+ Get Text h1 == Welcome
172
+ """
173
+ assert codes(tmp_path, body) == set()
174
+
175
+
176
+ def test_rpa_task_is_scanned(tmp_path):
177
+ body = """\
178
+ *** Tasks ***
179
+ Process Invoice
180
+ Open Application
181
+ Read Invoice Data
182
+ """
183
+ assert "C2b" in codes(tmp_path, body) # tasks (RPA) are analyzed like test cases
184
+
185
+
186
+ def test_d2_control_flow_diagnostic(tmp_path):
187
+ body = """\
188
+ *** Test Cases ***
189
+ Has Logic
190
+ Should Be Equal ${a} ${b}
191
+ IF ${cond}
192
+ Log branch
193
+ END
194
+ """
195
+ assert "D2" in codes(tmp_path, body)
196
+
197
+
198
+ def test_m2_long_task(tmp_path):
199
+ steps = "\n".join(" Log step %d" % i for i in range(12))
200
+ body = "*** Test Cases ***\nLong\n%s\n Should Be Equal ${a} ${b}\n" % steps
201
+ assert "M2" in codes(tmp_path, body)
202
+
203
+
204
+ def test_groups_by_prefix(tmp_path):
205
+ assert group_of("C2") == "false-positive"
206
+ assert group_of("R1") == "false-positive"
207
+ assert group_of("D2") == "diagnostic"
208
+ assert group_of("M2") == "coupling"
209
+
210
+
211
+ def test_scan_hides_diagnostics_by_default(tmp_path):
212
+ body = """\
213
+ *** Test Cases ***
214
+ Has Logic
215
+ Should Be Equal ${a} ${b}
216
+ IF ${cond}
217
+ Log x
218
+ END
219
+ """
220
+ f = tmp_path / "s.robot"
221
+ f.write_text(body, encoding="utf-8")
222
+ off = {x.code for x in scan([str(f)])}
223
+ on = {x.code for x in scan([str(f)], diagnostics=True)}
224
+ assert "D2" not in off # diagnostic group off by default
225
+ assert "D2" in on # surfaced with --diagnostics
226
+
227
+
228
+ def test_r2_hollow_verifier_keyword(tmp_path):
229
+ body = """\
230
+ *** Keywords ***
231
+ Verify Login Succeeded
232
+ Log checking login
233
+ Click id:next
234
+ """
235
+ assert "R2" in codes(tmp_path, body)
236
+
237
+
238
+ def test_no_r2_when_verifier_keyword_asserts(tmp_path):
239
+ body = """\
240
+ *** Keywords ***
241
+ Verify Login Succeeded
242
+ Should Be Equal ${status} ok
243
+ """
244
+ assert "R2" not in codes(tmp_path, body)
245
+
246
+
247
+ def test_action_keyword_not_flagged_as_r2(tmp_path):
248
+ body = """\
249
+ *** Keywords ***
250
+ Open The Application
251
+ Log opening
252
+ Click id:start
253
+ """
254
+ assert "R2" not in codes(tmp_path, body)
255
+
256
+
257
+ def test_c5_inside_user_keyword(tmp_path):
258
+ body = """\
259
+ *** Keywords ***
260
+ Check Result
261
+ Should Be True ${TRUE}
262
+ """
263
+ assert "C5" in codes(tmp_path, body)
264
+
265
+
266
+ def test_resource_file_is_scanned(tmp_path):
267
+ body = """\
268
+ *** Keywords ***
269
+ Validate Order
270
+ Log pretending to validate
271
+ """
272
+ assert "R2" in codes(tmp_path, body, name="keywords.resource")
273
+
274
+
275
+ def test_custom_verify_keyword_counts_as_oracle(tmp_path):
276
+ body = """\
277
+ *** Test Cases ***
278
+ Delegates
279
+ Do Login
280
+ Verify Dashboard Loaded
281
+ """
282
+ assert "C2b" not in codes(tmp_path, body)