Skip to content

Commit 720ddad

Browse files
authored
Merge pull request #3 from mittwald/chore/psalm-static-analysis
Add Psalm static analysis (composer analyse + CI job)
2 parents d0999d1 + 9e88095 commit 720ddad

10 files changed

Lines changed: 103 additions & 16 deletions

File tree

.github/workflows/ci.yml

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,15 @@ concurrency:
1515

1616
jobs:
1717
unit-tests:
18-
name: Unit tests (PHP ${{ matrix.php }})
18+
name: Unit tests
1919
runs-on: ubuntu-latest
20-
strategy:
21-
fail-fast: false
22-
matrix:
23-
php: ['8.1', '8.2', '8.3', '8.4']
2420
steps:
2521
- uses: actions/checkout@v4
2622

27-
- name: Set up PHP ${{ matrix.php }}
23+
- name: Set up PHP
2824
uses: shivammathur/setup-php@v2
2925
with:
30-
php-version: ${{ matrix.php }}
26+
php-version: '8.4'
3127
coverage: none
3228
tools: composer:v2
3329
extensions: imap, intl, mbstring
@@ -44,6 +40,29 @@ jobs:
4440
- name: Run unit tests
4541
run: composer test:unit -- --colors=never
4642

43+
static-analysis:
44+
name: Static analysis (Psalm)
45+
runs-on: ubuntu-latest
46+
steps:
47+
- uses: actions/checkout@v4
48+
49+
- name: Set up PHP
50+
uses: shivammathur/setup-php@v2
51+
with:
52+
php-version: '8.4'
53+
coverage: none
54+
tools: composer:v2
55+
extensions: imap, intl, mbstring
56+
57+
- name: Install dependencies
58+
uses: ramsey/composer-install@v3
59+
60+
- name: Fetch Roundcube source for type resolution
61+
run: composer dist:fetch
62+
63+
- name: Run Psalm
64+
run: composer analyse
65+
4766
integration-tests:
4867
name: Integration tests (Dovecot via Docker)
4968
runs-on: ubuntu-latest
@@ -54,7 +73,7 @@ jobs:
5473
- name: Set up PHP
5574
uses: shivammathur/setup-php@v2
5675
with:
57-
php-version: '8.3'
76+
php-version: '8.4'
5877
coverage: none
5978
tools: composer:v2
6079
extensions: imap, intl, mbstring

AGENTS.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ future worker mode — must use an out-of-session store (file, DB, Roundcube cac
5757
├── LICENSE # MIT
5858
├── README.md # user-facing documentation
5959
├── composer.json # plugin manifest (Roundcube plugin-installer type)
60+
├── psalm.xml # Psalm static-analysis configuration (no baseline — every error must be fixed)
6061
├── imapsync.php # plugin entry class (extends rcube_plugin)
6162
├── imapsync.js # client-side: form handling, result rendering
6263
├── config.inc.php.dist # default plugin config, copied by user to config.inc.php
@@ -91,7 +92,7 @@ future worker mode — must use an out-of-session store (file, DB, Roundcube cac
9192
├── .gitattributes # export-ignore tests/, dist/, .github/, etc.
9293
├── .github/
9394
│ └── workflows/
94-
│ ├── ci.yml # unit + integration tests, lint, docs-freshness
95+
│ ├── ci.yml # unit + integration tests, Psalm, lint, docs-freshness
9596
│ └── release.yml # tag → tarball + zip + checksums on GitHub Releases
9697
└── dist/ # gitignored — local Roundcube source for reference (see below)
9798
```
@@ -203,6 +204,8 @@ global rules win.
203204
call.
204205
- Run with `composer test:unit` (unit only) or `composer test:integration` (Docker, real
205206
Dovecot) from the repo root. CI runs both.
207+
- Run `composer analyse` for Psalm static analysis over `imapsync.php`, `lib/`, and `tests/`;
208+
it uses the Roundcube source in `dist/` for type resolution.
206209

207210
### Security expectations
208211

@@ -332,9 +335,9 @@ pre-commit check.
332335

333336
GitHub Actions (`.github/workflows/ci.yml`) runs the deterministic subset of these checks
334337
(config-key consistency, locale-key consistency) on every push and PR as the `docs-freshness`
335-
job, alongside `unit-tests`, `integration-tests`, and PHP linting. A failing freshness check
336-
blocks the PR. CI is a safety net, not a substitute — run the checks locally before pushing so
337-
you don't burn a round trip on something a 30-second grep would have caught.
338+
job, alongside `unit-tests`, `static-analysis`, `integration-tests`, and PHP linting. A failing
339+
freshness check blocks the PR. CI is a safety net, not a substitute — run the checks locally
340+
before pushing so you don't burn a round trip on something a 30-second grep would have caught.
338341

339342
---
340343

@@ -397,6 +400,7 @@ Test suite:
397400

398401
```bash
399402
composer install
403+
composer analyse # Psalm static analysis (uses dist/ for Roundcube symbols)
400404
composer test:unit # unit tests (fast, no network, no Docker)
401405
composer test:integration # integration tests (requires a running Docker daemon)
402406
```

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ composer install
187187
composer test:unit # in-memory unit tests, no network, no Docker
188188
composer test:integration # Dovecot integration suite, requires a running Docker daemon
189189
composer test # alias for test:unit
190+
composer analyse # Psalm static analysis over plugin code and tests
190191
```
191192

192193
The unit tests do not touch the network — the sync engine takes an injected IMAP-client

composer.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,21 @@
2929
},
3030
"require-dev": {
3131
"phpunit/phpunit": "^10.5",
32-
"testcontainers/testcontainers": "^1.0"
32+
"testcontainers/testcontainers": "^1.0",
33+
"vimeo/psalm": "^6.0"
3334
},
3435
"scripts": {
3536
"test": "@test:unit",
3637
"test:unit": "phpunit",
3738
"test:integration": "phpunit --configuration phpunit.integration.xml.dist",
39+
"analyse": "psalm --no-progress",
3840
"dist:fetch": "sh -c 'if [ -d dist/roundcubemail-1.7.0 ]; then echo \"dist/roundcubemail-1.7.0 already present, skipping.\"; exit 0; fi; curl -fLsS -o dist/roundcubemail-1.7.0-complete.tar.gz https://github.com/roundcube/roundcubemail/releases/download/1.7.0/roundcubemail-1.7.0-complete.tar.gz && tar -xzf dist/roundcubemail-1.7.0-complete.tar.gz -C dist && rm dist/roundcubemail-1.7.0-complete.tar.gz && echo \"dist/roundcubemail-1.7.0 ready.\"'"
3941
},
4042
"scripts-descriptions": {
4143
"test": "Alias for test:unit.",
4244
"test:unit": "Run the in-memory unit-test suite (fast, no Docker).",
4345
"test:integration": "Run the Dovecot integration suite (requires a running Docker daemon).",
46+
"analyse": "Run Psalm static analysis over imapsync.php, lib/, and tests/.",
4447
"dist:fetch": "Download and extract Roundcube 1.7.0 source into dist/ for local reference. Idempotent."
4548
},
4649
"autoload": {

imapsync.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class imapsync extends rcube_plugin
1515

1616
private rcmail $rc;
1717

18+
#[\Override]
1819
public function init(): void
1920
{
2021
$this->rc = rcmail::get_instance();
@@ -278,7 +279,7 @@ public function action_status(): void
278279
private function addFormRow(html_table $table, string $fieldId, string $labelKey, string $field): void
279280
{
280281
$table->add('title', html::label($fieldId, rcube::Q($this->gettext($labelKey))));
281-
$table->add(null, $field);
282+
$table->add('', $field);
282283
}
283284

284285
private function createJobFromRequest(): RoundcubeImapSyncJob

lib/RoundcubeImapSyncClient.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public function __construct(private readonly rcube_imap_generic $imap)
3535
{
3636
}
3737

38+
#[\Override]
3839
public function connect(string $host, int $port, string $user, string $password, array $options): void
3940
{
4041
$options['port'] = $port;
@@ -44,11 +45,13 @@ public function connect(string $host, int $port, string $user, string $password,
4445
}
4546
}
4647

48+
#[\Override]
4749
public function close(): void
4850
{
4951
$this->imap->closeConnection();
5052
}
5153

54+
#[\Override]
5255
public function listFolders(): array
5356
{
5457
$folders = $this->imap->listMailboxes('', '*');
@@ -59,18 +62,21 @@ public function listFolders(): array
5962
return array_values(array_map('strval', $folders));
6063
}
6164

65+
#[\Override]
6266
public function getHierarchyDelimiter(): string
6367
{
6468
$delimiter = $this->imap->getHierarchyDelimiter();
6569

6670
return is_string($delimiter) && $delimiter !== '' ? $delimiter : '/';
6771
}
6872

73+
#[\Override]
6974
public function createFolder(string $folder): bool
7075
{
7176
return $this->imap->createFolder($folder);
7277
}
7378

79+
#[\Override]
7480
public function selectFolder(string $folder): int
7581
{
7682
$status = $this->imap->status($folder, ['MESSAGES']);
@@ -81,6 +87,7 @@ public function selectFolder(string $folder): int
8187
return (int) ($status['MESSAGES'] ?? 0);
8288
}
8389

90+
#[\Override]
8491
public function getFolderSize(string $folder): int
8592
{
8693
if ($this->supportsStatusSize()) {
@@ -108,6 +115,7 @@ public function getFolderSize(string $folder): int
108115
return $totalSize;
109116
}
110117

118+
#[\Override]
111119
public function getQuota(string $folder): ?array
112120
{
113121
$quota = $this->imap->getQuota($folder);
@@ -121,6 +129,7 @@ public function getQuota(string $folder): ?array
121129
];
122130
}
123131

132+
#[\Override]
124133
public function supportsStatusSize(): bool
125134
{
126135
if ($this->statusSizeSupported === null) {
@@ -130,6 +139,7 @@ public function supportsStatusSize(): bool
130139
return $this->statusSizeSupported;
131140
}
132141

142+
#[\Override]
133143
public function fetchMessageIdentities(string $folder): array
134144
{
135145
$totalMessages = $this->selectFolder($folder);
@@ -160,6 +170,7 @@ public function fetchMessageIdentities(string $folder): array
160170
return $identities;
161171
}
162172

173+
#[\Override]
163174
public function fetchMessageRaw(string $folder, int $uid): ?array
164175
{
165176
$messages = $this->imap->fetch($folder, (string) $uid, true, ['UID', 'RFC822', 'FLAGS', 'INTERNALDATE']);
@@ -179,6 +190,7 @@ public function fetchMessageRaw(string $folder, int $uid): ?array
179190
];
180191
}
181192

193+
#[\Override]
182194
public function appendMessage(string $folder, string $rawMessage, array $flags, ?string $internalDate): bool
183195
{
184196
$message = $rawMessage;
@@ -193,7 +205,11 @@ public function appendMessage(string $folder, string $rawMessage, array $flags,
193205
throw new RoundcubeImapSyncException($errorMessage);
194206
}
195207

196-
return $result;
208+
// rcube_imap_generic::append returns mixed: false on failure, true on
209+
// plain success, or the appended UID (string) when the server supports
210+
// UIDPLUS. Our interface only contracts a bool, so collapse the
211+
// success cases to true.
212+
return (bool) $result;
197213
}
198214

199215
/**

psalm.xml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?xml version="1.0"?>
2+
<psalm
3+
errorLevel="6"
4+
resolveFromConfigFile="true"
5+
findUnusedBaselineEntry="true"
6+
findUnusedCode="false"
7+
cacheDirectory="/tmp/psalm-cache"
8+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
9+
xmlns="https://getpsalm.org/schema/config"
10+
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
11+
>
12+
<projectFiles>
13+
<file name="imapsync.php"/>
14+
<directory name="lib"/>
15+
<directory name="tests"/>
16+
</projectFiles>
17+
18+
<extraFiles>
19+
<directory name="dist/roundcubemail-1.7.0/program/lib/Roundcube"/>
20+
</extraFiles>
21+
22+
<!-- If errors in scope are unavoidable (Roundcube interop quirks),
23+
add <issueHandlers> here. Prefer baselining over suppressing. -->
24+
</psalm>

tests/Fakes/FakeImapClient.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,27 +23,32 @@ public function seed(string $folder, array $messages): void
2323
$this->folders[$folder] = $messages;
2424
}
2525

26+
#[\Override]
2627
public function connect(string $host, int $port, string $user, string $password, array $options): void
2728
{
2829
if ($this->connectShouldFail) {
2930
throw new RoundcubeImapSyncException('Source connect failed.');
3031
}
3132
}
3233

34+
#[\Override]
3335
public function close(): void
3436
{
3537
}
3638

39+
#[\Override]
3740
public function listFolders(): array
3841
{
3942
return array_keys($this->folders);
4043
}
4144

45+
#[\Override]
4246
public function getHierarchyDelimiter(): string
4347
{
4448
return $this->delimiter;
4549
}
4650

51+
#[\Override]
4752
public function createFolder(string $folder): bool
4853
{
4954
if (isset($this->createFolderFailures[$folder])) {
@@ -59,6 +64,7 @@ public function createFolder(string $folder): bool
5964
return false;
6065
}
6166

67+
#[\Override]
6268
public function selectFolder(string $folder): int
6369
{
6470
if (!isset($this->folders[$folder])) {
@@ -68,6 +74,7 @@ public function selectFolder(string $folder): int
6874
return count($this->folders[$folder]);
6975
}
7076

77+
#[\Override]
7178
public function getFolderSize(string $folder): int
7279
{
7380
if ($this->getFolderSizeShouldFail) {
@@ -90,16 +97,19 @@ public function getFolderSize(string $folder): int
9097
return $total;
9198
}
9299

100+
#[\Override]
93101
public function getQuota(string $folder): ?array
94102
{
95103
return $this->quotaResult;
96104
}
97105

106+
#[\Override]
98107
public function supportsStatusSize(): bool
99108
{
100109
return $this->statusSizeSupported;
101110
}
102111

112+
#[\Override]
103113
public function fetchMessageIdentities(string $folder): array
104114
{
105115
if (!isset($this->folders[$folder])) {
@@ -114,6 +124,7 @@ public function fetchMessageIdentities(string $folder): array
114124
return $identities;
115125
}
116126

127+
#[\Override]
117128
public function fetchMessageRaw(string $folder, int $uid): ?array
118129
{
119130
if (!isset($this->folders[$folder][$uid])) {
@@ -127,6 +138,7 @@ public function fetchMessageRaw(string $folder, int $uid): ?array
127138
];
128139
}
129140

141+
#[\Override]
130142
public function appendMessage(string $folder, string $rawMessage, array $flags, ?string $internalDate): bool
131143
{
132144
if (isset($this->appendQuotaFailures[$folder])) {
@@ -158,7 +170,10 @@ private function nextUid(string $folder): int
158170
return 1;
159171
}
160172

161-
return max(array_keys($this->folders[$folder])) + 1;
173+
/** @var non-empty-array<int, mixed> $messages */
174+
$messages = $this->folders[$folder];
175+
176+
return max(array_keys($messages)) + 1;
162177
}
163178

164179
private function identityFromRaw(string $rawMessage): array

tests/Integration/PreflightAndQuotaIntegrationTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ final class PreflightAndQuotaIntegrationTest extends TestCase
1515
private static ?DovecotContainer $sourceContainer = null;
1616
private static ?DovecotContainer $destinationContainer = null;
1717

18+
#[\Override]
1819
public static function setUpBeforeClass(): void
1920
{
2021
if (getenv('DOVECOT_INTEGRATION_SKIP') === '1') {
@@ -38,6 +39,7 @@ public static function setUpBeforeClass(): void
3839
self::seedSourceContainer();
3940
}
4041

42+
#[\Override]
4143
public static function tearDownAfterClass(): void
4244
{
4345
try {

0 commit comments

Comments
 (0)