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.
- falsegreen_robot-0.1.0/.github/workflows/ci.yml +28 -0
- falsegreen_robot-0.1.0/.github/workflows/release.yml +47 -0
- falsegreen_robot-0.1.0/.gitignore +17 -0
- falsegreen_robot-0.1.0/CHANGELOG.md +34 -0
- falsegreen_robot-0.1.0/LICENSE +21 -0
- falsegreen_robot-0.1.0/PKG-INFO +116 -0
- falsegreen_robot-0.1.0/README.md +93 -0
- falsegreen_robot-0.1.0/pyproject.toml +39 -0
- falsegreen_robot-0.1.0/src/falsegreen_robot/__init__.py +3 -0
- falsegreen_robot-0.1.0/src/falsegreen_robot/__main__.py +6 -0
- falsegreen_robot-0.1.0/src/falsegreen_robot/scanner.py +408 -0
- falsegreen_robot-0.1.0/tests/test_scanner.py +282 -0
|
@@ -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,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,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)
|