Skip to content

Commit a00bbbb

Browse files
committed
feat: improve popular package analysis
1 parent 6358bed commit a00bbbb

9 files changed

Lines changed: 426 additions & 61 deletions

File tree

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
POPULAR_LIMIT ?= 20
2+
POPULAR_MIN_BYTES ?= 1024
3+
4+
analyze-popular:
5+
php check-top-packagist.php --limit=$(POPULAR_LIMIT) --min-bytes=$(POPULAR_MIN_BYTES)
6+
17
cs:
28
PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --diff --verbose
39

@@ -20,4 +26,3 @@ composer-req:
2026
vendor/bin/composer-require-checker check
2127

2228
quality: cs rector composer-normalize composer-unused composer-req phpstan test
23-

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,28 @@ The workflow updates `dist-size-status.json`, and the badge reflects your reposi
5353

5454
## Usage
5555

56+
### Analyze Popular Packages
57+
58+
The repository includes a conservative discovery script for finding potential
59+
optimizations in popular Packagist packages:
60+
61+
```bash
62+
make analyze-popular
63+
```
64+
65+
The script analyzes a fresh package set by default, ranks observations by the
66+
uncompressed size of conservative development-only paths, and writes the full
67+
report to `var/results.json`. Observations below 1 KiB remain in the report but
68+
are not treated as actionable candidates. Use `--min-bytes=0` to change that
69+
threshold, `--resume` to skip packages already stored in `var/analyzed.json`,
70+
or `--config=/path/to/config.php` to select another pattern set.
71+
The Make target defaults to 20 packages; override it with
72+
`POPULAR_LIMIT=100 make analyze-popular`.
73+
74+
Discovery results are leads, not ready-made pull requests. Before proposing a
75+
change, verify the exact source revision and compare real `git archive` output
76+
before and after applying the suggested `.gitattributes` rules.
77+
5678
### Check Current Project
5779

5880
To check your current project (recommended during development):

check-top-packagist.php

Lines changed: 195 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -3,130 +3,267 @@
33

44
declare(strict_types=1);
55

6-
declare(ticks=1); // ensures signal checks happen between statements
6+
declare(ticks=1);
77

88
require __DIR__ . '/vendor/autoload.php';
99

10+
use SavinMikhail\DistSizeOptimizer\Analysis\AnalysisReport;
1011
use SavinMikhail\DistSizeOptimizer\Command\CheckCommand;
12+
use SavinMikhail\DistSizeOptimizer\PackageManager\PackageManager;
1113
use Symfony\Component\Console\Input\ArrayInput;
1214
use Symfony\Component\Console\Output\BufferedOutput;
1315

1416
const ANALYZED_FILE = __DIR__ . '/var/analyzed.json';
1517
const RESULT_FILE = __DIR__ . '/var/results.json';
18+
const DEFAULT_CONFIG_FILE = __DIR__ . '/export-ignore.safe.php';
1619

1720
$interrupted = false;
1821

1922
pcntl_signal(SIGINT, static function () use (&$interrupted): void {
20-
echo "\n⛔️ Interrupted. Finishing up...\n";
23+
echo "\nInterrupted. Saving the completed results...\n";
2124
$interrupted = true;
2225
});
2326

27+
/** @return array{limit: int, minimumCandidateBytes: int, resume: bool, config: string} */
28+
function parseOptions(): array
29+
{
30+
$options = getopt(short_options: '', long_options: ['limit:', 'min-bytes:', 'resume', 'config:']);
31+
$limit = filter_var(value: $options['limit'] ?? 1_000, filter: FILTER_VALIDATE_INT);
32+
$minimumCandidateBytes = filter_var(value: $options['min-bytes'] ?? 1_024, filter: FILTER_VALIDATE_INT);
33+
34+
if ($limit === false || $limit < 1) {
35+
throw new InvalidArgumentException(message: '--limit must be a positive integer');
36+
}
37+
38+
if ($minimumCandidateBytes === false || $minimumCandidateBytes < 0) {
39+
throw new InvalidArgumentException(message: '--min-bytes must be a non-negative integer');
40+
}
41+
42+
$configOption = $options['config'] ?? DEFAULT_CONFIG_FILE;
43+
if (!is_string(value: $configOption)) {
44+
throw new InvalidArgumentException(message: '--config must be a file path');
45+
}
46+
47+
if (!is_file(filename: $configOption)) {
48+
throw new InvalidArgumentException(message: "Config file not found: {$configOption}");
49+
}
50+
51+
return [
52+
'limit' => $limit,
53+
'minimumCandidateBytes' => $minimumCandidateBytes,
54+
'resume' => array_key_exists(key: 'resume', array: $options),
55+
'config' => realpath(path: $configOption) ?: $configOption,
56+
];
57+
}
58+
59+
/** @return array<string, array{status: string, packageMetadata: mixed, details: mixed, error?: string}> */
2460
function loadAnalyzed(): array
2561
{
26-
if (!file_exists(ANALYZED_FILE)) {
62+
if (!is_file(filename: ANALYZED_FILE)) {
2763
return [];
2864
}
2965

30-
return json_decode(file_get_contents(ANALYZED_FILE), true, 512, JSON_THROW_ON_ERROR);
66+
$contents = file_get_contents(filename: ANALYZED_FILE);
67+
68+
return is_string(value: $contents)
69+
? json_decode(json: $contents, associative: true, flags: JSON_THROW_ON_ERROR)
70+
: [];
3171
}
3272

33-
function fetchTopPackages(int $limit = 1_000): array
73+
/** @return list<string> */
74+
function fetchTopPackages(int $limit): array
3475
{
3576
$packages = [];
36-
$perPage = 100;
77+
$perPage = min(100, $limit);
3778
$pages = (int) ceil($limit / $perPage);
3879

3980
for ($page = 1; $page <= $pages; ++$page) {
4081
$url = "https://packagist.org/explore/popular.json?per_page={$perPage}&page={$page}";
41-
$json = file_get_contents($url);
82+
$json = file_get_contents(filename: $url);
4283

43-
if (!$json) {
44-
throw new RuntimeException("Failed to fetch Packagist data for page {$page}");
84+
if ($json === false) {
85+
throw new RuntimeException(message: "Failed to fetch Packagist data for page {$page}");
4586
}
4687

47-
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
48-
$pagePackages = array_map(static fn($pkg) => $pkg['name'], $data['packages']);
88+
$data = json_decode(json: $json, associative: true, flags: JSON_THROW_ON_ERROR);
89+
$pagePackages = array_map(
90+
callback: static fn(array $package): string => $package['name'],
91+
array: $data['packages'],
92+
);
4993
$packages = array_merge($packages, $pagePackages);
5094

51-
// Stop early if we already have enough
52-
if (count($packages) >= $limit) {
95+
if (count(value: $packages) >= $limit) {
5396
break;
5497
}
5598
}
5699

57-
return array_slice($packages, 0, $limit);
100+
return array_values(array: array_slice(array: $packages, offset: 0, length: $limit));
58101
}
59102

60-
function runExportIgnoreCheck(string $package): array
103+
/** @return list<string> */
104+
function requireStringList(mixed $value, string $field): array
61105
{
62-
$command = new CheckCommand();
63-
$input = new ArrayInput([
106+
if (!is_array(value: $value)) {
107+
throw new RuntimeException(message: "The checker returned an invalid {$field} list");
108+
}
109+
110+
$strings = [];
111+
foreach ($value as $item) {
112+
if (!is_string(value: $item)) {
113+
throw new RuntimeException(message: "The checker returned a non-string {$field} entry");
114+
}
115+
116+
$strings[] = $item;
117+
}
118+
119+
return $strings;
120+
}
121+
122+
/**
123+
* @param array<mixed, mixed> $details
124+
*
125+
* @return array{files: string[], directories: string[], suggestions: string[], totalSizeBytes: int, humanReadableSize: string}
126+
*/
127+
function normalizeDetails(array $details): array
128+
{
129+
$totalSizeBytes = $details['totalSizeBytes'] ?? null;
130+
$humanReadableSize = $details['humanReadableSize'] ?? null;
131+
132+
if (!is_int(value: $totalSizeBytes) || !is_string(value: $humanReadableSize)) {
133+
throw new RuntimeException(message: 'The checker returned invalid size information');
134+
}
135+
136+
return [
137+
'files' => requireStringList(value: $details['files'] ?? null, field: 'files'),
138+
'directories' => requireStringList(value: $details['directories'] ?? null, field: 'directories'),
139+
'suggestions' => requireStringList(value: $details['suggestions'] ?? null, field: 'suggestions'),
140+
'totalSizeBytes' => $totalSizeBytes,
141+
'humanReadableSize' => $humanReadableSize,
142+
];
143+
}
144+
145+
/**
146+
* @return array{status: 'ok'|'candidate'|'error', packageMetadata: null|array{name: string, version: string, sourceUrl: null|string, sourceReference: null|string, distUrl: null|string, distReference: null|string}, details: null|array{files: string[], directories: string[], suggestions: string[], totalSizeBytes: int, humanReadableSize: string}, error?: string}
147+
*/
148+
function analyzePackage(string $package, string $config): array
149+
{
150+
$input = new ArrayInput(parameters: [
64151
'package' => $package,
65152
'--json' => true,
153+
'--dry-run' => true,
154+
'--config' => $config,
66155
]);
67156
$output = new BufferedOutput();
157+
$packageManager = new PackageManager();
68158

69159
try {
70-
$exitCode = $command->run($input, $output);
71-
$result = json_decode($output->fetch(), true);
72-
160+
$exitCode = new CheckCommand(packageManager: $packageManager)->run(input: $input, output: $output);
161+
$packageMetadata = $packageManager->getPackageMetadata();
73162
if ($exitCode === 0) {
74-
return ['status' => '✅ OK', 'details' => null];
163+
return ['status' => 'ok', 'packageMetadata' => $packageMetadata, 'details' => null];
75164
}
76165

77-
if (isset($result['files']) && count($result['files']) > 0 || isset($result['directories']) && count($result['directories']) > 0) {
78-
return ['status' => '❌ Missing export-ignore', 'details' => $result];
166+
$details = json_decode(json: $output->fetch(), associative: true, flags: JSON_THROW_ON_ERROR);
167+
if (!is_array(value: $details)) {
168+
throw new RuntimeException(message: 'The checker returned an invalid JSON report');
79169
}
80170

81-
return ['status' => '⚠️ Failed', 'details' => null];
82-
} catch (Exception $e) {
83-
return ['status' => '💥 Error: ' . $e->getMessage(), 'details' => null];
171+
return [
172+
'status' => 'candidate',
173+
'packageMetadata' => $packageMetadata,
174+
'details' => normalizeDetails(details: $details),
175+
];
176+
} catch (Throwable $error) {
177+
return [
178+
'status' => 'error',
179+
'packageMetadata' => null,
180+
'details' => null,
181+
'error' => $error->getMessage(),
182+
];
84183
}
85184
}
86185

87-
function saveFailures(array $failures): void
186+
/** @param array<string, mixed> $data */
187+
function writeJson(string $path, array $data): void
88188
{
89-
if (count($failures) === 0) {
90-
echo "\n✅ No export-ignore issues found. Great job, open source!\n";
91-
92-
return;
189+
$directory = dirname(path: $path);
190+
if (!is_dir(filename: $directory)) {
191+
mkdir(directory: $directory, permissions: 0o777, recursive: true);
93192
}
94193

95-
file_put_contents(RESULT_FILE, json_encode($failures, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
96-
echo "\n📝 Saved detailed results to: " . RESULT_FILE . "\n";
194+
file_put_contents(
195+
filename: $path,
196+
data: json_encode(value: $data, flags: JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n",
197+
);
97198
}
98199

99-
echo "🔍 Checking top Packagist packages...\n";
100-
101-
$packages = fetchTopPackages(1_000);
102-
103-
$results = [];
104-
$failures = [];
105-
106-
$total = count($packages);
107-
$analyzed = loadAnalyzed();
108-
$newPackages = array_filter($packages, static fn($p) => !isset($analyzed[$p]));
109-
110-
foreach (array_values($newPackages) as $i => $package) {
200+
$options = parseOptions();
201+
$packages = fetchTopPackages(limit: $options['limit']);
202+
$previousResults = $options['resume'] ? loadAnalyzed() : [];
203+
$results = $previousResults;
204+
$packagesToAnalyze = array_values(array_filter(
205+
array: $packages,
206+
callback: static fn(string $package): bool => !array_key_exists(key: $package, array: $previousResults),
207+
));
208+
209+
echo sprintf(
210+
"Checking %d popular Packagist packages with %s rules...\n",
211+
count(value: $packagesToAnalyze),
212+
basename(path: $options['config']),
213+
);
214+
215+
foreach ($packagesToAnalyze as $index => $package) {
111216
if ($interrupted) {
112217
break;
113218
}
114219

115-
echo "📦 [{$i}/{$total}] Checking {$package}...\n";
116-
$check = runExportIgnoreCheck($package);
117-
$results[$package] = $check['status'];
220+
echo sprintf("[%d/%d] %s\n", $index + 1, count(value: $packagesToAnalyze), $package);
221+
$results[$package] = analyzePackage(package: $package, config: $options['config']);
222+
writeJson(path: ANALYZED_FILE, data: $results);
223+
}
118224

119-
if ($check['status'] === '❌ Missing export-ignore' && isset($check['details'])) {
120-
$failures[$package] = $check['details'];
225+
$selectedResults = array_intersect_key($results, array_flip($packages));
226+
$observations = new AnalysisReport()->rankCandidates(results: $selectedResults);
227+
$candidates = array_values(array_filter(
228+
array: $observations,
229+
callback: static fn(array $candidate): bool => $candidate['totalSizeBytes'] >= $options['minimumCandidateBytes'],
230+
));
231+
$report = [
232+
'generatedAt' => gmdate(format: DATE_ATOM),
233+
'requestedLimit' => $options['limit'],
234+
'analyzedCount' => count(value: $selectedResults),
235+
'minimumCandidateBytes' => $options['minimumCandidateBytes'],
236+
'config' => basename(path: $options['config']),
237+
'interrupted' => $interrupted,
238+
'candidates' => $candidates,
239+
'observations' => $observations,
240+
'packages' => $selectedResults,
241+
];
242+
writeJson(path: RESULT_FILE, data: $report);
243+
244+
echo sprintf("\nSaved the reproducible report to %s\n", RESULT_FILE);
245+
246+
if ($candidates === []) {
247+
echo sprintf(
248+
"No conservative export-ignore candidates found above %d bytes.\n",
249+
$options['minimumCandidateBytes'],
250+
);
251+
252+
if ($observations !== []) {
253+
echo sprintf(
254+
"Largest observation below the threshold: %s (%s)\n",
255+
$observations[0]['package'],
256+
$observations[0]['humanReadableSize'],
257+
);
121258
}
122-
}
123259

124-
echo "\n📊 Results:\n";
125-
foreach ($results as $package => $status) {
126-
echo " {$package}: {$status}\n";
260+
exit(0);
127261
}
128262

129-
saveFailures($failures);
130-
131-
$allAnalyzed = array_merge($analyzed, $results);
132-
file_put_contents(ANALYZED_FILE, json_encode($allAnalyzed, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
263+
$topCandidate = $candidates[0];
264+
echo sprintf(
265+
"Top candidate: %s (%s across %d paths)\n",
266+
$topCandidate['package'],
267+
$topCandidate['humanReadableSize'],
268+
count(value: $topCandidate['files']) + count(value: $topCandidate['directories']),
269+
);

0 commit comments

Comments
 (0)