Skip to content

Commit f9c035b

Browse files
ZviBaratzclaude
andauthored
feat(ego-lint): add .destroy without parentheses detection (R-LIFE-23) (#50)
## Summary - Add Tier 2 check in `check-lifecycle.py` detecting `.destroy` property access without `()` — a silent no-op bug where objects are never destroyed - Suppress false positives for callback references (`connect`/`connectSmart`/`bind`), existence checks (`if`/`typeof`), property assignments, and destructuring - Add test fixture with TP (forEach arrow body) and FP patterns (connect callback, existence check, typeof check) Field test validation: - **blur-my-shell**: correctly detects `coverflow_alt_tab.js:69` (`actors.forEach(a => a.destroy)`) - **appindicator**: correctly suppresses `connectSmart(menu, 'destroy', this, this.destroy)` callback reference Closes #38 ## Test plan - [x] `bash tests/run-tests.sh` — 562 passed, 0 failed - [x] Field test: blur-my-shell TP detected, appindicator FP suppressed - [x] Fixture covers: forEach arrow body (TP), connectSmart callback (FP), if-check (FP), typeof-check (FP) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 546bd10 commit f9c035b

File tree

6 files changed

+155
-0
lines changed

6 files changed

+155
-0
lines changed

skills/ego-lint/references/rules-reference.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2084,6 +2084,22 @@ Rules for APIs removed or changed in specific GNOME Shell versions. These rules
20842084
- **Known limitations**: Multi-line `.connect(\n'changed::...'` calls are not detected (per-line scanning). Signal IDs stored via `.push()` rather than direct assignment may false-positive.
20852085
- **Tested by**: `tests/fixtures/gsettings-bare-connect@test/`, `tests/fixtures/gsettings-auto-cleanup@test/`
20862086
2087+
### R-LIFE-23: .destroy without parentheses
2088+
- **Severity**: advisory
2089+
- **Checked by**: check-lifecycle.py
2090+
- **Rule**: `.destroy` must be called as a method (`.destroy()`), not accessed as a property (`.destroy`).
2091+
- **Rationale**: In JavaScript, `obj.destroy` without parentheses is a property access that evaluates to the function object but does not call it. The object is never destroyed, causing a silent resource leak. This is a common typo, especially in `forEach` callbacks: `actors.forEach(a => a.destroy)` does nothing.
2092+
- **Fix**: Add parentheses: `actors.forEach(a => a.destroy())`.
2093+
- **Not matched**: `.destroy()` and `.destroy?.()` (actual calls), `.destroyAll()` etc. (different methods) are excluded by the primary regex pattern.
2094+
- **Suppressed when**:
2095+
- `connect`/`connectSmart`/`connectObject(` before `.destroy` (callback reference, but not `disconnect()`)
2096+
- `.bind(`/`.call(`/`.apply(` before or after `.destroy` (Function.prototype reference)
2097+
- `.destroy` inside an `if()` condition or preceded by `typeof` (existence check)
2098+
- Followed by `=` but not `==` (property assignment)
2099+
- `{ destroy }` on the same line (destructuring)
2100+
- Ternary `?` after `.destroy` but not `?.` (existence check)
2101+
- **Tested by**: `tests/fixtures/destroy-no-call@test/`
2102+
20872103
---
20882104
20892105
## Security (R-SEC) — continued

skills/ego-lint/scripts/check-lifecycle.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
- R-LIFE-20: Bus name ownership without release
2626
- R-LIFE-21: GSettings signal leak (bare connect without disconnect)
2727
- R-LIFE-22: Stage actor leak (add_child/addChrome without remove)
28+
- R-LIFE-23: .destroy without parentheses (property access, not method call)
2829
- R-LIFE-24: MessageTray source leak (add without destroy)
2930
- R-SEC-16: Clipboard + keybinding cross-reference
3031
- R-FILE-07: Missing export default class
@@ -947,6 +948,79 @@ def check_bus_name_lifecycle(ext_dir):
947948
# If no bus name ownership, skip silently
948949

949950

951+
def check_destroy_without_call(ext_dir):
952+
"""R-LIFE-23: .destroy without () is property access, not method call."""
953+
js_files = find_js_files(ext_dir, exclude_prefs=True)
954+
if not js_files:
955+
return
956+
957+
violations = []
958+
for filepath in js_files:
959+
content = strip_comments(read_file(filepath))
960+
rel = os.path.relpath(filepath, ext_dir)
961+
962+
for lineno, line in enumerate(content.splitlines(), 1):
963+
# Match .destroy at word boundary (excludes .destroyAll etc.), not followed by () or ?.() (excludes actual calls)
964+
for m in re.finditer(r'\.destroy\b(?!\s*(\?\.)?\s*\()', line):
965+
before = line[:m.start()]
966+
after = line[m.end():]
967+
968+
# Skip: callback reference in signal connection
969+
if re.search(r'(?<!dis)connect\w*\s*\(', before):
970+
continue
971+
# Skip: Function.prototype reference (.bind/.call/.apply)
972+
if re.search(r'\.(bind|call|apply)\s*\(', before):
973+
continue
974+
if re.search(r'\.(bind|call|apply)\s*\(', after):
975+
continue
976+
977+
# Skip: property existence check (only when .destroy is inside the condition)
978+
if re.search(r'\btypeof\b', before):
979+
continue
980+
if_match = re.search(r'\bif\s*\(', before)
981+
if if_match:
982+
# Check .destroy is inside the condition, not in the body
983+
after_if_open = before[if_match.end():]
984+
depth = 1
985+
for ch in after_if_open:
986+
if ch == '(':
987+
depth += 1
988+
elif ch == ')':
989+
depth -= 1
990+
if depth == 0:
991+
break
992+
if depth > 0:
993+
# Condition still open — .destroy is being tested
994+
continue
995+
996+
# Skip: ternary existence check (e.g., actor.destroy ? actor.destroy() : null)
997+
after_stripped = after.lstrip()
998+
if after_stripped.startswith('?') and not after_stripped.startswith('?.'):
999+
continue
1000+
1001+
# Skip: property assignment (not comparison)
1002+
if after_stripped.startswith('=') and not after_stripped.startswith('=='):
1003+
continue
1004+
1005+
# Skip: destructuring
1006+
if re.search(r'\{\s*destroy\b', line):
1007+
continue
1008+
1009+
violations.append(f"{rel}:{lineno}")
1010+
1011+
if violations:
1012+
locs = violations[:5]
1013+
for loc in locs:
1014+
result("WARN", "lifecycle/destroy-no-call",
1015+
f"{loc}: .destroy without () — property access, not method call")
1016+
if len(violations) > 5:
1017+
result("WARN", "lifecycle/destroy-no-call",
1018+
f"(and {len(violations) - 5} more)")
1019+
else:
1020+
result("PASS", "lifecycle/destroy-no-call",
1021+
"All .destroy references include parentheses")
1022+
1023+
9501024
def check_widget_lifecycle(ext_dir):
9511025
"""Detect widgets created in enable() but not destroyed in disable()."""
9521026
ext_file = os.path.join(ext_dir, 'extension.js')
@@ -1239,6 +1313,7 @@ def main():
12391313
check_clipboard_network(ext_dir)
12401314
check_soup_session_abort(ext_dir)
12411315
check_destroy_then_null(ext_dir)
1316+
check_destroy_without_call(ext_dir)
12421317
check_widget_lifecycle(ext_dir)
12431318
check_stage_actor_lifecycle(ext_dir)
12441319
check_message_tray_lifecycle(ext_dir)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
SPDX-License-Identifier: GPL-2.0-or-later
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
2+
import St from 'gi://St';
3+
import * as Util from './util.js';
4+
5+
export default class TestExtension extends Extension {
6+
enable() {
7+
this._actors = [new St.Label(), new St.Button()];
8+
this._widget = new St.Label({text: 'test'});
9+
}
10+
11+
disable() {
12+
// BAD: .destroy without () — property access, not method call
13+
this._actors.forEach((actor) => actor.destroy);
14+
15+
// BAD: .destroy in if-body, not condition (should still flag)
16+
if (this._shouldCleanup) this._widget.destroy;
17+
18+
// GOOD: .destroy() with parens
19+
this._widget.destroy();
20+
21+
// GOOD: optional chaining call (FP suppression)
22+
this._widget.destroy?.();
23+
this._widget = null;
24+
25+
// GOOD: callback reference in connectSmart (FP suppression)
26+
Util.connectSmart(this._widget, 'destroy', this, this.destroy);
27+
28+
// GOOD: existence check (FP suppression)
29+
if (this._widget.destroy) {
30+
this._widget.destroy();
31+
}
32+
33+
// GOOD: typeof check (FP suppression)
34+
if (typeof this._widget.destroy === 'function') {
35+
this._widget.destroy();
36+
}
37+
38+
// GOOD: bound method reference (FP suppression)
39+
const cleanup = this._widget.destroy.bind(this._widget);
40+
41+
// GOOD: .call/.apply invocation (FP suppression)
42+
St.Widget.prototype.destroy.call(this._widget);
43+
St.Widget.prototype.destroy.apply(this._widget, []);
44+
45+
// GOOD: property assignment (FP suppression)
46+
this._widget.destroy = null;
47+
48+
// GOOD: ternary existence check (FP suppression)
49+
this._widget.destroy ? this._widget.destroy() : null;
50+
51+
// GOOD: destructuring (FP suppression)
52+
const {destroy} = this._widget;
53+
}
54+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"uuid": "destroy-no-call@test", "name": "Destroy No Call Test", "description": "Tests .destroy without parentheses detection", "shell-version": ["48"], "url": "https://github.com/test/destroy-no-call"}

tests/run-tests.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,14 @@ assert_output_not_contains "no no-destroy-method for widget with onDestroy" "\[W
634634
assert_output_contains "resource tracking ran" "(PASS|WARN|FAIL).*resource-tracking"
635635
echo ""
636636

637+
# --- destroy-no-call ---
638+
echo "=== destroy-no-call ==="
639+
run_lint "destroy-no-call@test"
640+
assert_exit_code "exits with 0 (advisory only)" 0
641+
assert_output_contains "detects .destroy without parens" "\[WARN\].*lifecycle/destroy-no-call.*without \(\)"
642+
assert_output_count "exactly 2 destroy-no-call warnings" "\[WARN\].*lifecycle/destroy-no-call" 2
643+
echo ""
644+
637645
# Extended assertion files (auto-sourced from assertions/ directory)
638646
ASSERTIONS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/assertions"
639647
for assertion_file in "$ASSERTIONS_DIR"/*.sh; do

0 commit comments

Comments
 (0)