Skip to content

Commit 0dbe7e8

Browse files
authored
Merge branch 'main' into feat/field-test-pipeline
2 parents d1e3199 + 7cea626 commit 0dbe7e8

File tree

33 files changed

+425
-39
lines changed

33 files changed

+425
-39
lines changed
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Field Test #10: Just Perfection
2+
3+
## Pre-flight
4+
5+
- **Extension**: Just Perfection (`just-perfection-desktop@just-perfection`)
6+
- **Source**: https://github.com/jrahmatzadeh/just-perfection
7+
- **GNOME versions**: 45, 46, 47, 48, 49, 50
8+
- **File count**: 7 JS files, 2 CSS files; ~5,300 JS lines (Manager.js alone is 1,636 non-blank)
9+
- **License**: GPL-3.0-only (in repo root, not in `src/`)
10+
- **Why this extension**: Most popular GNOME extension; exercises connectObject/disconnectObject signal management (67 signals), prototype backup/restore via `#originals` map, `src/` directory layout, GNOME 50 support (widest version range), CSS-heavy (23.7%), private field usage (`#timeoutIds`, `#originals`, `#shellVersion`), 20 Shell.ui imports
11+
12+
## Step 1: ego-lint
13+
14+
```bash
15+
./ego-lint --verbose /tmp/just-perfection/src
16+
```
17+
18+
### Results (before fixes)
19+
20+
| Status | Count |
21+
|--------|-------|
22+
| PASS | 202 |
23+
| FAIL | 3 |
24+
| WARN | 414 |
25+
| SKIP | 12 |
26+
| Exit | 1 |
27+
28+
### Classification of Each FAIL
29+
30+
| Rule | File:Line | TP/FP | Root Cause (if FP) |
31+
|------|-----------|-------|-------------------|
32+
| `license` || **FP** | LICENSE in repo root, not in `src/`; no parent-dir fallback for src/ layout |
33+
| `R-VER48-07` | stylesheet.css:167 | TP | `.quick-menu-toggle` renamed to `.quick-toggle-has-menu` in GNOME 48 |
34+
| `metadata/future-shell-version` || TP | GNOME 50 > CURRENT_STABLE (49) |
35+
36+
### Classification of Key WARNs
37+
38+
| Rule | Count | Classification | Notes |
39+
|------|-------|---------------|-------|
40+
| R-SLOP-01 (JSDoc @param) | 129 | **FP** | Provenance score=3 but threshold was >=4; PR #23 fixes threshold to >=3 |
41+
| R-SLOP-02 (JSDoc @returns) | 271 | **FP** | Same root cause as R-SLOP-01 |
42+
| `lifecycle/prototype-override` | 5 (3 unique) | TP + **bug** | Advisory is valid (prototypes restored via close() chain). Regex catches both override AND restore → duplicates |
43+
| `metadata/uuid-matches-dir` | 1 | **FP** | Dir is `src`, not UUID; meaningless for src/ layout |
44+
| `metadata/session-modes-consistency` | 1 | **FP** | `sessionMode.isLocked` used as guard — not lock screen functionality |
45+
| `quality/impossible-state` | 1 | **FP** | Same isLocked guard pattern |
46+
| `quality/comment-density` | 1 | **FP** | SupportNotifier.js has 40% comments from thorough hand-written JSDoc |
47+
| `metadata/deprecated-version` | 1 | TP | `version` field ignored by EGO for GNOME 45+ |
48+
| `quality/file-complexity` | 1 | TP | Manager.js 1,636 non-blank lines |
49+
| `quality/private-api` | 1 | TP | statusArea access (legitimate for a UI tweaker) |
50+
| `disclosure/private-api` | 1 | TP | Private API not disclosed in metadata description |
51+
52+
## Fixes Implemented
53+
54+
Split across 3 PRs per one-concern-per-PR convention:
55+
56+
### PR: fix/src-layout-license (closes #26)
57+
58+
| Rule/Check | Root Cause | Fix |
59+
|------------|-----------|-----|
60+
| `license` (FAIL) | No parent-dir fallback for src/ layout | `ego-lint.sh`: check parent dir when `basename == "src"` |
61+
| `metadata/uuid-matches-dir` | Dir is `src` for src/ layout | `check-metadata.py`: skip when `dir_name == "src"` |
62+
63+
### PR: fix/islocked-guard (closes #27)
64+
65+
| Rule/Check | Root Cause | Fix |
66+
|------------|-----------|-----|
67+
| `session-modes-consistency` | `isLocked` is always a guard | `check-metadata.py`: narrow regex to `sessionMode.currentMode` only |
68+
| `quality/impossible-state` | `isLocked` is always a guard | `check-quality.py`: remove `isLocked` from check, keep `currentMode` comparisons |
69+
| `quality/comment-density` | 40% threshold too aggressive | `check-quality.py`: raise threshold from 40% to 50% |
70+
71+
### PR: fix/proto-override-dedup (closes #28)
72+
73+
| Rule/Check | Root Cause | Fix |
74+
|------------|-----------|-----|
75+
| `lifecycle/prototype-override` (dupes) | Regex matches override + restore | `check-lifecycle.py`: deduplicate by `(file, prototype)` key |
76+
77+
### Test Fixtures Added
78+
79+
| Fixture | What It Tests |
80+
|---------|--------------|
81+
| `src-license-fallback@test` | License found in parent dir for src/ layout; UUID-dir skip |
82+
| `islocked-guard@test` | `sessionMode.isLocked` guard not flagged as impossible-state or session-modes inconsistency |
83+
| `proto-override-dedup@test` | Each prototype warned only once despite override + restore pattern |
84+
85+
### Existing Fixture Updated
86+
87+
| Fixture | Change | Reason |
88+
|---------|--------|--------|
89+
| `ai-slop@test` | `sessionMode.isLocked``sessionMode.currentMode !== 'unlock-dialog'` | `isLocked` no longer triggers impossible-state (by design) |
90+
91+
## Results (after all fixes)
92+
93+
| Status | Before | After | Change |
94+
|--------|--------|-------|--------|
95+
| FAIL | 3 | 2 | -1 (license FP fixed) |
96+
| WARN | 414 | 408 | -6 (5 FPs + 2 dupes fixed) |
97+
98+
**With PR #23 merged** (provenance threshold >=3): WARNs would drop to ~8, all true positives.
99+
100+
## Calibration Lessons Learned
101+
102+
1. **`sessionMode.isLocked` is always a guard**: Reading lock state to decide behavior is defensive coding, not lock screen functionality. Only `currentMode === 'unlock-dialog'` comparisons suggest active lock screen mode usage.
103+
104+
2. **`src/` layout needs holistic support**: License check and UUID-dir match didn't handle src/ layout. Multiple checks already had src/ fallbacks (CSS, prefs, metadata), but these two were missed.
105+
106+
3. **Prototype override + restore creates duplicates**: When an extension stores originals and restores them (Just Perfection's `#originals` map pattern), the same regex catches both the override and the restore. Deduplication by `(file, prototype_name)` is essential.
107+
108+
4. **Comment-density threshold interacts with provenance**: Well-documented hand-written code (provenance=3) naturally has higher comment density (40%+) from thorough JSDoc. The 40% threshold was too aggressive; 50% avoids FPs while still catching AI slop (typically 55-70%).
109+
110+
5. **Just Perfection validates PR #23**: Provenance score=3 with 400 JSDoc warnings confirms the threshold change from >=4 to >=3 is needed.

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

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -831,7 +831,7 @@ Rules that detect patterns commonly found in AI-generated extensions. These are
831831
- **Rule**: JSDoc comments should not use TypeScript-style `@param {Type} name` annotations.
832832
- **Rationale**: GNOME Shell extensions are plain JavaScript (not TypeScript). JSDoc type annotations in the `{Type}` format are a strong signal that code was generated by an AI trained primarily on TypeScript codebases. EGO reviewers recognize this pattern.
833833
- **Fix**: Remove type annotations from JSDoc comments, or remove JSDoc entirely if the code is self-documenting. GJS does not process JSDoc types.
834-
- **Provenance suppression**: When `quality/code-provenance` reports a score >= 4 (strong hand-written indicators), R-SLOP-01 warnings are automatically suppressed. High-provenance code uses JSDoc as intentional documentation, not AI slop. A `provenance/jsdoc-suppressed` PASS line is emitted instead.
834+
- **Provenance suppression**: When `quality/code-provenance` reports a score >= 3 (moderate-to-high hand-written indicators), R-SLOP-01 warnings are automatically suppressed. Hand-written code uses JSDoc as intentional documentation, not AI slop. A `provenance/jsdoc-suppressed` PASS line is emitted instead.
835835

836836
#### Example
837837

@@ -862,7 +862,7 @@ _updateIndicator(level, charging, icon) {
862862
- **Rule**: JSDoc comments should not use TypeScript-style `@returns {Type}` annotations.
863863
- **Rationale**: Same as R-SLOP-01. `@returns {Type}` is a TypeScript convention that has no effect in GJS and signals AI-generated code.
864864
- **Fix**: Remove `@returns {Type}` annotations from JSDoc comments.
865-
- **Provenance suppression**: Same as R-SLOP-01 — suppressed when provenance score >= 4.
865+
- **Provenance suppression**: Same as R-SLOP-01 — suppressed when provenance score >= 3.
866866

867867
#### Example
868868

@@ -921,9 +921,9 @@ Heuristic rules that detect code patterns commonly seen in AI-generated or over-
921921
### R-QUAL-02: Impossible state checks
922922
- **Severity**: advisory
923923
- **Checked by**: check-quality.py
924-
- **Rule**: Extension code should not check for states that are impossible given its configuration. For example, checking `Main.sessionMode.isLocked` without declaring `unlock-dialog` in `session-modes`.
925-
- **Rationale**: If the extension does not declare `unlock-dialog` in `session-modes`, it is never active on the lock screen, so checking lock state is dead code. This pattern is common in AI-generated extensions that copy-paste lock-screen handling without understanding when it applies.
926-
- **Fix**: Either add `"unlock-dialog"` to `session-modes` in `metadata.json` (if lock-screen support is intended) or remove the lock-state checking code.
924+
- **Rule**: Extension code should not check for states that are impossible given its configuration. For example, comparing `Main.sessionMode.currentMode` with `'unlock-dialog'` without declaring `unlock-dialog` in `session-modes`. Note: `sessionMode.isLocked` is exempt — reading lock state is always a defensive guard pattern, not evidence of lock screen functionality.
925+
- **Rationale**: If the extension does not declare `unlock-dialog` in `session-modes`, it is never active on the lock screen, so checking `currentMode` against lock-screen values is dead code. This pattern is common in AI-generated extensions that copy-paste lock-screen handling without understanding when it applies.
926+
- **Fix**: Either add `"unlock-dialog"` to `session-modes` in `metadata.json` (if lock-screen support is intended) or remove the `currentMode` comparison code.
927927

928928
### R-QUAL-03: Over-engineered async coordination
929929
- **Severity**: advisory
@@ -1561,7 +1561,7 @@ destroy() {
15611561
### R-QUAL-11: Comment Density
15621562
- **Severity**: advisory
15631563
- **Checked by**: check-quality.py
1564-
- **Rule**: Flags files where >40% of lines (after first 10) are comments.
1564+
- **Rule**: Flags files where >50% of lines (after first 10) are comments.
15651565
- **Rationale**: Excessive comments explaining obvious code is a strong AI slop signal.
15661566
- **Fix**: Remove redundant comments. Only keep comments that explain non-obvious logic or API quirks.
15671567
- **Tested by**: `tests/fixtures/quality-signals@test/`
@@ -1952,7 +1952,7 @@ Rules for APIs removed or changed in specific GNOME Shell versions. These rules
19521952
### metadata/session-modes-consistency: SessionMode usage without declaration
19531953
- **Severity**: advisory
19541954
- **Checked by**: check-metadata.py
1955-
- **Rule**: Warns if the extension code references `Main.sessionMode.currentMode` or `sessionMode.isLocked` but `metadata.json` does not declare `session-modes` with `unlock-dialog`.
1955+
- **Rule**: Warns if the extension code references `Main.sessionMode.currentMode` but `metadata.json` does not declare `session-modes` with `unlock-dialog`. Note: `sessionMode.isLocked` is exempt — reading lock state is a defensive guard, not evidence of lock screen usage.
19561956
- **Rationale**: Code that checks session mode without the proper declaration is either dead code (the extension won't run in lock screen mode) or a misunderstanding of the lifecycle.
19571957
- **Fix**: Either add `"session-modes": ["user", "unlock-dialog"]` to metadata.json, or remove the session mode checks from the code.
19581958

@@ -2011,8 +2011,8 @@ Rules for APIs removed or changed in specific GNOME Shell versions. These rules
20112011
### resource-tracking/no-destroy-method
20122012
- **Severity**: advisory
20132013
- **Checked by**: check-resources.py → build-resource-graph.py
2014-
- **Rule**: Module creates resources but has no `destroy()` or `disable()` method for cleanup.
2015-
- **Fix**: Add a `destroy()` method that cleans up all resources.
2014+
- **Rule**: Module creates resources but has no cleanup method (`destroy()`, `disable()`, or `onDestroy()`) for cleanup.
2015+
- **Fix**: Add a `destroy()` method that cleans up all resources, or use `onDestroy()` connected via `this.connect('destroy', this.onDestroy.bind(this))`.
20162016
20172017
### resource-tracking/destroy-not-called
20182018
- **Severity**: advisory

skills/ego-lint/scripts/apply-patterns.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ def _unescape_yaml_double(s):
9191
def _get_shell_versions(ext_dir):
9292
"""Read shell-version from metadata.json and return as list of ints."""
9393
metadata_path = os.path.join(ext_dir, 'metadata.json')
94+
# src/ layout fallback
95+
if not os.path.isfile(metadata_path):
96+
src_path = os.path.join(ext_dir, 'src', 'metadata.json')
97+
if os.path.isfile(src_path):
98+
metadata_path = src_path
9499
if not os.path.isfile(metadata_path):
95100
return []
96101
try:

skills/ego-lint/scripts/build-resource-graph.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,10 @@ def scan_file(file_path, ext_dir):
329329
has_private_destroy = bool(re.search(
330330
r'(?:^|\s)_destroy\w*\s*\([^)]*\)\s*\{', content, re.MULTILINE
331331
))
332+
# Detect onDestroy() — common pattern via this.connect("destroy", this.onDestroy.bind(this))
333+
has_on_destroy = bool(re.search(
334+
r'(?:^|\s)onDestroy\s*\([^)]*\)\s*\{', content, re.MULTILINE
335+
))
332336

333337
return {
334338
'rel': rel,
@@ -339,6 +343,7 @@ def scan_file(file_path, ext_dir):
339343
'has_destroy': has_destroy,
340344
'has_disable': has_disable,
341345
'has_private_destroy': has_private_destroy,
346+
'has_on_destroy': has_on_destroy,
342347
'child_refs': child_refs,
343348
'content': content,
344349
}
@@ -436,9 +441,9 @@ def detect_orphans(file_scans, ownership):
436441
"""Detect orphaned resources in files that are owned by other files.
437442
438443
An orphan is a resource created in a child module where:
439-
1. No matching cleanup exists in that module's destroy() method, OR
440-
2. The module has no destroy() method but creates resources, OR
441-
3. The module's destroy() is never called by its parent.
444+
1. The module has no cleanup method (destroy/disable/onDestroy) but creates resources, OR
445+
2. The module has a cleanup method but its parent never calls it, OR
446+
3. The cleanup method exists and is called, but specific resources aren't cleaned up.
442447
443448
Only flags resources in files that ARE owned by another file.
444449
"""
@@ -463,7 +468,8 @@ def detect_orphans(file_scans, ownership):
463468
has_destroy = scan['has_destroy']
464469
has_disable = scan['has_disable']
465470
has_private_destroy = scan['has_private_destroy']
466-
has_cleanup_method = has_destroy or has_disable or has_private_destroy
471+
has_on_destroy = scan['has_on_destroy']
472+
has_cleanup_method = has_destroy or has_disable or has_private_destroy or has_on_destroy
467473

468474
if not creates:
469475
continue
@@ -486,27 +492,27 @@ def detect_orphans(file_scans, ownership):
486492
parent_calls_destroy = True
487493
break
488494

489-
# Case 1: Module creates resources but has no destroy/disable method
495+
# Case 1: Module creates resources but has no cleanup method
490496
if not has_cleanup_method:
491497
for c in creates:
492498
orphans.append({
493499
'file': rel,
494500
'line': c['line'],
495501
'type': c['type'],
496502
'pattern': c['pattern'],
497-
'reason': f"no destroy()/disable() method in {rel}",
503+
'reason': f"no cleanup method (destroy/disable/onDestroy) in {rel}",
498504
})
499505
continue
500506

501-
# Case 2: Module has destroy() but parent never calls it
507+
# Case 2: Module has cleanup method but parent never calls it
502508
if not parent_calls_destroy:
503509
for c in creates:
504510
orphans.append({
505511
'file': rel,
506512
'line': c['line'],
507513
'type': c['type'],
508514
'pattern': c['pattern'],
509-
'reason': f"parent does not call destroy() on {rel}",
515+
'reason': f"parent does not call cleanup method on {rel}",
510516
})
511517
continue
512518

@@ -517,7 +523,7 @@ def detect_orphans(file_scans, ownership):
517523
# releasing references even when the resource auto-cleans itself
518524
nulled_refs = set()
519525
content = scan['content']
520-
for method_name in ('destroy', 'disable', '_destroy'):
526+
for method_name in ('destroy', 'disable', '_destroy', 'onDestroy'):
521527
mb = find_method_body(content, method_name)
522528
if mb:
523529
_, _, body = mb

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,11 @@ def main():
152152

153153
# Read metadata description once
154154
meta_path = os.path.join(ext_dir, 'metadata.json')
155+
# src/ layout fallback
156+
if not os.path.isfile(meta_path):
157+
src_path = os.path.join(ext_dir, 'src', 'metadata.json')
158+
if os.path.isfile(src_path):
159+
meta_path = src_path
155160
description = ''
156161
if os.path.isfile(meta_path):
157162
try:

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -374,15 +374,23 @@ def check_injection_manager(ext_dir):
374374

375375
# WS1-D: Detect direct prototype overrides
376376
prototype_overrides = []
377+
seen_overrides = set()
377378
for filepath in js_files:
378379
content = strip_comments(read_file(filepath))
379380
rel = os.path.relpath(filepath, ext_dir)
380381
# SomeClass.prototype.methodName = ...
381382
for m in re.finditer(r'(\w+\.prototype\.\w+)\s*=', content):
382-
prototype_overrides.append((rel, m.group(1)))
383+
key = (rel, m.group(1))
384+
if key not in seen_overrides:
385+
seen_overrides.add(key)
386+
prototype_overrides.append(key)
383387
# Object.assign(SomeClass.prototype, ...)
384388
for m in re.finditer(r'Object\.assign\s*\(\s*(\w+\.prototype)', content):
385-
prototype_overrides.append((rel, f"Object.assign({m.group(1)}, ...)"))
389+
label = f"Object.assign({m.group(1)}, ...)"
390+
key = (rel, label)
391+
if key not in seen_overrides:
392+
seen_overrides.add(key)
393+
prototype_overrides.append(key)
386394

387395
if prototype_overrides:
388396
# Check if disable() restores prototypes
@@ -464,6 +472,10 @@ def check_selective_disable(ext_dir):
464472
def check_unlock_dialog_comment(ext_dir):
465473
"""R-LIFE-14: unlock-dialog session mode should have explanatory comment in disable()."""
466474
metadata_path = os.path.join(ext_dir, 'metadata.json')
475+
if not os.path.isfile(metadata_path):
476+
src_path = os.path.join(ext_dir, 'src', 'metadata.json')
477+
if os.path.isfile(src_path):
478+
metadata_path = src_path
467479
if not os.path.isfile(metadata_path):
468480
return
469481

@@ -541,6 +553,10 @@ def check_clipboard_keybinding(ext_dir):
541553
def check_lockscreen_signals(ext_dir):
542554
"""R-LIFE-11: Lock screen signal safety — keyboard signals with unlock-dialog mode."""
543555
metadata_path = os.path.join(ext_dir, 'metadata.json')
556+
if not os.path.isfile(metadata_path):
557+
src_path = os.path.join(ext_dir, 'src', 'metadata.json')
558+
if os.path.isfile(src_path):
559+
metadata_path = src_path
544560
if not os.path.isfile(metadata_path):
545561
return
546562

0 commit comments

Comments
 (0)